From 0aaac17141ca257714da4b3c6f4a2a8338943eea Mon Sep 17 00:00:00 2001 From: Alexis <3876562+alexis-@users.noreply.github.com> Date: Fri, 6 Feb 2026 17:15:29 +0100 Subject: [PATCH 1/3] Initial Commit --- .gitattributes | 63 ++ .github/workflows/CI.yml | 102 ++++ .github/workflows/build.yml | 94 +++ .github/workflows/publish.yml | 179 ++++++ .gitignore | 418 ++++++++++++++ Directory.Build.props | 70 +++ LICENSE.txt | 203 +++++++ README.md | 253 ++++++++ Smtp2Go.NET.pub | Bin 0 -> 160 bytes Smtp2Go.NET.slnx | 11 + Smtp2Go.NET.snk | Bin 0 -> 596 bytes scripts/setup-secrets.ps1 | 113 ++++ .../Configuration/ResilienceOptions.cs | 174 ++++++ .../Configuration/Smtp2GoOptions.cs | 75 +++ .../Configuration/Smtp2GoOptionsValidator.cs | 64 +++ src/Smtp2Go.NET/Core/Smtp2GoResource.cs | 205 +++++++ .../Exceptions/Smtp2GoApiException.cs | 85 +++ .../Smtp2GoConfigurationException.cs | 81 +++ .../Exceptions/Smtp2GoException.cs | 44 ++ src/Smtp2Go.NET/Http/HttpClientExtensions.cs | 280 +++++++++ src/Smtp2Go.NET/ISmtp2GoClient.cs | 74 +++ src/Smtp2Go.NET/ISmtp2GoStatisticsClient.cs | 39 ++ src/Smtp2Go.NET/ISmtp2GoWebhookClient.cs | 44 ++ src/Smtp2Go.NET/Internal/LoggingConstants.cs | 107 ++++ .../Internal/Smtp2GoJsonDefaults.cs | 25 + src/Smtp2Go.NET/Models/ApiResponse.cs | 53 ++ src/Smtp2Go.NET/Models/Email/Attachment.cs | 66 +++ src/Smtp2Go.NET/Models/Email/CustomHeader.cs | 52 ++ .../Models/Email/EmailSendRequest.cs | 173 ++++++ .../Models/Email/EmailSendResponse.cs | 71 +++ .../Models/Statistics/EmailSummaryRequest.cs | 40 ++ .../Models/Statistics/EmailSummaryResponse.cs | 159 +++++ src/Smtp2Go.NET/Models/Webhooks/BounceType.cs | 207 +++++++ .../Models/Webhooks/WebhookCallbackEvent.cs | 201 +++++++ .../Models/Webhooks/WebhookCallbackPayload.cs | 182 ++++++ .../Models/Webhooks/WebhookCreateEvent.cs | 238 ++++++++ .../Models/Webhooks/WebhookCreateRequest.cs | 98 ++++ .../Models/Webhooks/WebhookCreateResponse.cs | 33 ++ .../Models/Webhooks/WebhookDeleteResponse.cs | 30 + .../Models/Webhooks/WebhookListResponse.cs | 68 +++ .../ServiceCollectionExtensions.cs | 182 ++++++ src/Smtp2Go.NET/Smtp2Go.NET.csproj | 46 ++ src/Smtp2Go.NET/Smtp2GoClient.cs | 138 +++++ src/Smtp2Go.NET/Smtp2GoStatisticsClient.cs | 84 +++ src/Smtp2Go.NET/Smtp2GoWebhookClient.cs | 136 +++++ temp | 1 - tests/Directory.Build.props | 45 ++ .../Email/EmailSendLiveIntegrationTests.cs | 78 +++ .../Email/EmailSendSandboxIntegrationTests.cs | 336 +++++++++++ .../Fixtures/CloudflareTunnelManager.cs | 542 ++++++++++++++++++ .../Fixtures/Smtp2GoLiveFixture.cs | 67 +++ .../Fixtures/Smtp2GoSandboxFixture.cs | 65 +++ .../Fixtures/TestConfiguration.cs | 115 ++++ .../Fixtures/WebhookReceiverFixture.cs | 300 ++++++++++ .../Helpers/Smtp2GoClientFactory.cs | 73 +++ .../Helpers/TestSecretValidator.cs | 107 ++++ .../Smtp2Go.NET.IntegrationTests.csproj | 37 ++ .../EmailSummaryIntegrationTests.cs | 88 +++ .../WebhookDeliveryIntegrationTests.cs | 383 +++++++++++++ .../WebhookManagementIntegrationTests.cs | 185 ++++++ .../appsettings.json | 11 + .../Smtp2GoOptionsValidatorTests.cs | 250 ++++++++ .../EmailSendRequestSerializationTests.cs | 153 +++++ .../WebhookPayloadDeserializationTests.cs | 307 ++++++++++ .../Smtp2Go.NET.UnitTests.csproj | 38 ++ .../Smtp2GoClientTests.cs | 395 +++++++++++++ tests/Smtp2Go.NET.UnitTests/appsettings.json | 7 + 67 files changed, 8642 insertions(+), 1 deletion(-) create mode 100644 .gitattributes create mode 100644 .github/workflows/CI.yml create mode 100644 .github/workflows/build.yml create mode 100644 .github/workflows/publish.yml create mode 100644 .gitignore create mode 100644 Directory.Build.props create mode 100644 LICENSE.txt create mode 100644 README.md create mode 100644 Smtp2Go.NET.pub create mode 100644 Smtp2Go.NET.slnx create mode 100644 Smtp2Go.NET.snk create mode 100644 scripts/setup-secrets.ps1 create mode 100644 src/Smtp2Go.NET/Configuration/ResilienceOptions.cs create mode 100644 src/Smtp2Go.NET/Configuration/Smtp2GoOptions.cs create mode 100644 src/Smtp2Go.NET/Configuration/Smtp2GoOptionsValidator.cs create mode 100644 src/Smtp2Go.NET/Core/Smtp2GoResource.cs create mode 100644 src/Smtp2Go.NET/Exceptions/Smtp2GoApiException.cs create mode 100644 src/Smtp2Go.NET/Exceptions/Smtp2GoConfigurationException.cs create mode 100644 src/Smtp2Go.NET/Exceptions/Smtp2GoException.cs create mode 100644 src/Smtp2Go.NET/Http/HttpClientExtensions.cs create mode 100644 src/Smtp2Go.NET/ISmtp2GoClient.cs create mode 100644 src/Smtp2Go.NET/ISmtp2GoStatisticsClient.cs create mode 100644 src/Smtp2Go.NET/ISmtp2GoWebhookClient.cs create mode 100644 src/Smtp2Go.NET/Internal/LoggingConstants.cs create mode 100644 src/Smtp2Go.NET/Internal/Smtp2GoJsonDefaults.cs create mode 100644 src/Smtp2Go.NET/Models/ApiResponse.cs create mode 100644 src/Smtp2Go.NET/Models/Email/Attachment.cs create mode 100644 src/Smtp2Go.NET/Models/Email/CustomHeader.cs create mode 100644 src/Smtp2Go.NET/Models/Email/EmailSendRequest.cs create mode 100644 src/Smtp2Go.NET/Models/Email/EmailSendResponse.cs create mode 100644 src/Smtp2Go.NET/Models/Statistics/EmailSummaryRequest.cs create mode 100644 src/Smtp2Go.NET/Models/Statistics/EmailSummaryResponse.cs create mode 100644 src/Smtp2Go.NET/Models/Webhooks/BounceType.cs create mode 100644 src/Smtp2Go.NET/Models/Webhooks/WebhookCallbackEvent.cs create mode 100644 src/Smtp2Go.NET/Models/Webhooks/WebhookCallbackPayload.cs create mode 100644 src/Smtp2Go.NET/Models/Webhooks/WebhookCreateEvent.cs create mode 100644 src/Smtp2Go.NET/Models/Webhooks/WebhookCreateRequest.cs create mode 100644 src/Smtp2Go.NET/Models/Webhooks/WebhookCreateResponse.cs create mode 100644 src/Smtp2Go.NET/Models/Webhooks/WebhookDeleteResponse.cs create mode 100644 src/Smtp2Go.NET/Models/Webhooks/WebhookListResponse.cs create mode 100644 src/Smtp2Go.NET/ServiceCollectionExtensions.cs create mode 100644 src/Smtp2Go.NET/Smtp2Go.NET.csproj create mode 100644 src/Smtp2Go.NET/Smtp2GoClient.cs create mode 100644 src/Smtp2Go.NET/Smtp2GoStatisticsClient.cs create mode 100644 src/Smtp2Go.NET/Smtp2GoWebhookClient.cs delete mode 100644 temp create mode 100644 tests/Directory.Build.props create mode 100644 tests/Smtp2Go.NET.IntegrationTests/Email/EmailSendLiveIntegrationTests.cs create mode 100644 tests/Smtp2Go.NET.IntegrationTests/Email/EmailSendSandboxIntegrationTests.cs create mode 100644 tests/Smtp2Go.NET.IntegrationTests/Fixtures/CloudflareTunnelManager.cs create mode 100644 tests/Smtp2Go.NET.IntegrationTests/Fixtures/Smtp2GoLiveFixture.cs create mode 100644 tests/Smtp2Go.NET.IntegrationTests/Fixtures/Smtp2GoSandboxFixture.cs create mode 100644 tests/Smtp2Go.NET.IntegrationTests/Fixtures/TestConfiguration.cs create mode 100644 tests/Smtp2Go.NET.IntegrationTests/Fixtures/WebhookReceiverFixture.cs create mode 100644 tests/Smtp2Go.NET.IntegrationTests/Helpers/Smtp2GoClientFactory.cs create mode 100644 tests/Smtp2Go.NET.IntegrationTests/Helpers/TestSecretValidator.cs create mode 100644 tests/Smtp2Go.NET.IntegrationTests/Smtp2Go.NET.IntegrationTests.csproj create mode 100644 tests/Smtp2Go.NET.IntegrationTests/Statistics/EmailSummaryIntegrationTests.cs create mode 100644 tests/Smtp2Go.NET.IntegrationTests/Webhooks/WebhookDeliveryIntegrationTests.cs create mode 100644 tests/Smtp2Go.NET.IntegrationTests/Webhooks/WebhookManagementIntegrationTests.cs create mode 100644 tests/Smtp2Go.NET.IntegrationTests/appsettings.json create mode 100644 tests/Smtp2Go.NET.UnitTests/Configuration/Smtp2GoOptionsValidatorTests.cs create mode 100644 tests/Smtp2Go.NET.UnitTests/Models/EmailSendRequestSerializationTests.cs create mode 100644 tests/Smtp2Go.NET.UnitTests/Models/WebhookPayloadDeserializationTests.cs create mode 100644 tests/Smtp2Go.NET.UnitTests/Smtp2Go.NET.UnitTests.csproj create mode 100644 tests/Smtp2Go.NET.UnitTests/Smtp2GoClientTests.cs create mode 100644 tests/Smtp2Go.NET.UnitTests/appsettings.json diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..5896c16 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,63 @@ +############################################################################### +# Set default behavior to automatically normalize line endings. +############################################################################### +* text=auto + +############################################################################### +# Set default behavior for command prompt diff. +# +# This is need for earlier builds of msysgit that does not have it on by +# default for csharp files. +# Note: This is only used by command line +############################################################################### +#*.cs diff=csharp + +############################################################################### +# Set the merge driver for project and solution files +# +# Merging from the command prompt will add diff markers to the files if there +# are conflicts (Merging from VS is not affected by the settings below, in VS +# the diff markers are never inserted). Diff markers may cause the following +# file extensions to fail to load in VS. An alternative would be to treat +# these files as binary and thus will always conflict and require user +# intervention with every merge. To do so, just uncomment the entries below +############################################################################### +#*.sln merge=binary +#*.csproj merge=binary +#*.vbproj merge=binary +#*.vcxproj merge=binary +#*.vcproj merge=binary +#*.dbproj merge=binary +#*.fsproj merge=binary +#*.lsproj merge=binary +#*.wixproj merge=binary +#*.modelproj merge=binary +#*.sqlproj merge=binary +#*.wwaproj merge=binary + +############################################################################### +# behavior for image files +# +# image files are treated as binary by default. +############################################################################### +#*.jpg binary +#*.png binary +#*.gif binary + +############################################################################### +# diff behavior for common document formats +# +# Convert binary document formats to text before diffing them. This feature +# is only available from the command line. Turn it on by uncommenting the +# entries below. +############################################################################### +#*.doc diff=astextplain +#*.DOC diff=astextplain +#*.docx diff=astextplain +#*.DOCX diff=astextplain +#*.dot diff=astextplain +#*.DOT diff=astextplain +#*.pdf diff=astextplain +#*.PDF diff=astextplain +#*.rtf diff=astextplain +#*.RTF diff=astextplain diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml new file mode 100644 index 0000000..bdfb10d --- /dev/null +++ b/.github/workflows/CI.yml @@ -0,0 +1,102 @@ +# Continuous Integration workflow. +# +# This workflow runs on every push and pull request to main branch: +# 1. Builds the solution using the reusable build workflow +# 2. Runs unit tests (no network required) +# 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. + +name: CI + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + +# Cancel in-progress runs when a new run is triggered for the same branch/PR. +# This prevents resource conflicts and saves CI minutes. +# Reference: https://docs.github.com/en/actions/using-jobs/using-concurrency +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +# Restrict default permissions for security +permissions: + contents: read + +jobs: + # Build the solution using the reusable workflow + build: + uses: ./.github/workflows/build.yml + with: + configuration: Debug + upload_artifacts: true + + # Run unit tests (no secrets required) + unit-tests: + needs: build + runs-on: ubuntu-latest + timeout-minutes: 10 + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 10.0.x + + - name: Download build artifacts + uses: actions/download-artifact@v4 + with: + name: build-output-Debug + + - name: Run unit tests + # xUnit v3 test projects are standalone executables — dotnet test does not discover them. + 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). + # Sandbox and live API tests run with secrets configured as environment variables. + integration-tests: + needs: build + runs-on: ubuntu-latest + timeout-minutes: 15 + # Only run on push to main or when secrets are available (not on fork PRs). + if: github.event_name == 'push' || github.event.pull_request.head.repo.full_name == github.repository + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 10.0.x + + - name: Download build artifacts + uses: actions/download-artifact@v4 + with: + name: build-output-Debug + + - name: Run integration tests (excluding webhook delivery) + # Exclude webhook delivery tests (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. + 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. + # These map to the configuration keys used by TestConfiguration: + # Smtp2Go:ApiKey:Sandbox → Smtp2Go__ApiKey__Sandbox + # Smtp2Go:ApiKey:Live → Smtp2Go__ApiKey__Live + # Smtp2Go:TestSender → Smtp2Go__TestSender + # Smtp2Go:TestRecipient → Smtp2Go__TestRecipient + Smtp2Go__ApiKey__Sandbox: ${{ secrets.SMTP2GO_API_KEY_SANDBOX }} + Smtp2Go__ApiKey__Live: ${{ secrets.SMTP2GO_API_KEY_LIVE }} + Smtp2Go__TestSender: ${{ secrets.SMTP2GO_TEST_SENDER }} + Smtp2Go__TestRecipient: ${{ secrets.SMTP2GO_TEST_RECIPIENT }} diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..95e707c --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,94 @@ +# Reusable workflow for building the solution. +# +# This workflow is designed to be called by other workflows to avoid +# redundant compilation. It builds the solution and uploads the build +# artifacts for downstream jobs to consume. +# +# Usage in other workflows: +# jobs: +# build: +# uses: ./.github/workflows/build.yml +# with: +# configuration: Release +# +# dependent-job: +# needs: build +# steps: +# - uses: actions/download-artifact@v4 +# with: +# name: build-output-Release + +name: Build + +on: + # Allow this workflow to be called by other workflows + workflow_call: + inputs: + configuration: + description: 'Build configuration (Debug or Release)' + required: false + default: 'Debug' + type: string + upload_artifacts: + description: 'Whether to upload build artifacts' + required: false + default: true + type: boolean + + # Also allow standalone execution for testing + workflow_dispatch: + inputs: + configuration: + description: 'Build configuration' + required: false + default: 'Debug' + type: choice + options: + - Debug + - Release + +# Restrict default permissions for security +permissions: + contents: read + +jobs: + build: + runs-on: ubuntu-latest + timeout-minutes: 15 + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 # Full history for version calculation + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: | + 8.0.x + 9.0.x + 10.0.x + + - name: Restore dependencies + run: dotnet restore Smtp2Go.NET.slnx + + - name: Build solution + # Build the entire solution with the specified configuration + run: dotnet build Smtp2Go.NET.slnx --configuration ${{ inputs.configuration }} --no-restore + + - name: Upload build artifacts + # Upload the build output for downstream jobs to consume + # This avoids the need to rebuild in dependent workflows + if: ${{ inputs.upload_artifacts }} + uses: actions/upload-artifact@v4 + with: + name: build-output-${{ inputs.configuration }} + path: | + src/**/bin/${{ inputs.configuration }} + src/**/obj/${{ inputs.configuration }} + tests/**/bin/${{ inputs.configuration }} + tests/**/obj/${{ inputs.configuration }} + samples/**/bin/${{ inputs.configuration }} + samples/**/obj/${{ inputs.configuration }} + retention-days: 1 diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..67695d5 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,179 @@ +# GitHub Actions workflow for publishing NuGet packages using Trusted Publishing. +# +# This workflow automates the publishing process: +# 1. Triggers only after CI workflow passes on main branch +# 2. Uses the reusable build workflow to compile in Release configuration +# 3. Authenticates via OIDC (Trusted Publishing) - no long-lived API keys needed +# 4. Pushes packages to NuGet.org (skipping already-published versions) +# 5. Creates Git tags for each published package (v1.0.0 format) +# +# Triggers: +# - Automatically after CI workflow succeeds on main branch +# - When a GitHub Release is published +# - Manual workflow dispatch (for testing or re-publishing) +# +# Prerequisites: +# 1. Configure Trusted Publishing on NuGet.org: +# - Go to https://www.nuget.org/account/trustedpublishing +# - Add a new trusted publisher policy +# - Repository owner: +# - Repository name: +# - Workflow file: .github/workflows/publish.yml +# - Select packages this policy applies to +# +# 2. Configure repository secret: +# - NUGET_USER: Your NuGet.org username + +name: Publish NuGet Packages + +on: + # Trigger after CI workflow completes on main branch + workflow_run: + workflows: ["CI"] + types: [completed] + branches: [main] + + release: + types: [published] + + # Allow manual trigger for testing or re-publishing + workflow_dispatch: + inputs: + dry_run: + description: 'Dry run (build and pack only, no push)' + required: false + default: false + type: boolean + +# Restrict default permissions for security +permissions: + contents: write # Required for creating Git tags + id-token: write # Required for OIDC Trusted Publishing + +# Allow only one concurrent publish to prevent race conditions +concurrency: + group: "nuget-publish" + cancel-in-progress: false + +jobs: + # Build the solution in Release configuration + build: + # Only run if CI succeeded (for workflow_run trigger) or for other triggers + if: ${{ github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == 'success' }} + uses: ./.github/workflows/build.yml + with: + configuration: Release + upload_artifacts: true + + # Pack and publish + publish: + needs: build + runs-on: ubuntu-latest + timeout-minutes: 15 + + permissions: + contents: write # Required for creating Git tags + id-token: write # Required for OIDC Trusted Publishing + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: | + 8.0.x + 9.0.x + 10.0.x + + - name: Download build artifacts + uses: actions/download-artifact@v4 + with: + name: build-output-Release + + - name: Restore dependencies + run: dotnet restore Smtp2Go.NET.slnx + + - name: Create NuGet packages + # Pack all projects that produce NuGet packages + # The version is determined by the property in each .csproj file + run: dotnet pack Smtp2Go.NET.slnx --configuration Release --no-build --output ./artifacts + + - name: List generated packages + # Display the packages that were created for verification + run: | + echo "Generated packages:" + ls -la ./artifacts/*.nupkg + + - name: NuGet Trusted Publishing Login + # Exchange GitHub OIDC token for a short-lived NuGet API key + # This eliminates the need for long-lived API key secrets + # Reference: https://github.com/NuGet/login + if: ${{ github.event.inputs.dry_run != 'true' }} + id: nuget-login + uses: nuget/login@v1 + with: + user: ${{ secrets.NUGET_USER }} + + - name: Push packages to NuGet.org + # Push all packages to NuGet.org + # --skip-duplicate ensures idempotency - already published versions are skipped + # The API key is obtained from the login step's output (valid for ~1 hour) + if: ${{ github.event.inputs.dry_run != 'true' }} + run: | + for package in ./artifacts/*.nupkg; do + echo "Pushing: $package" + dotnet nuget push "$package" \ + --api-key "${{ steps.nuget-login.outputs.NUGET_API_KEY }}" \ + --source https://api.nuget.org/v3/index.json \ + --skip-duplicate + done + + - name: Create Git tags for published packages + # Create and push Git tags for each successfully published package + # Tag format: v{version} (e.g., v1.0.0) + if: ${{ github.event.inputs.dry_run != 'true' }} + run: | + # Configure Git for tagging + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + # Process each package and create corresponding tags + for package in ./artifacts/*.nupkg; do + # Extract filename without path (e.g., "Smtp2Go.NET.1.0.0.nupkg") + filename=$(basename "$package") + + # Remove .nupkg extension + name_version="${filename%.nupkg}" + + # Extract version from the package name + # Pattern: PackageName.Major.Minor.Patch.nupkg + # Use regex to extract version (last 3 dot-separated numbers) + if [[ "$name_version" =~ ([0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.]+)?)$ ]]; then + version="${BASH_REMATCH[1]}" + tag="v${version}" + + # Check if tag already exists (locally or remotely) + if git rev-parse "refs/tags/$tag" >/dev/null 2>&1; then + echo "Tag $tag already exists locally, skipping" + elif git ls-remote --tags origin "refs/tags/$tag" | grep -q "$tag"; then + echo "Tag $tag already exists on remote, skipping" + else + echo "Creating tag: $tag" + git tag -a "$tag" -m "Release $tag" + git push origin "$tag" + echo "Successfully created and pushed tag: $tag" + fi + else + echo "Could not extract version from: $filename" + fi + done + + - name: Upload packages as artifacts + # Upload packages as workflow artifacts for inspection or manual deployment + uses: actions/upload-artifact@v4 + with: + name: nuget-packages + path: ./artifacts/*.nupkg + retention-days: 30 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ce89292 --- /dev/null +++ b/.gitignore @@ -0,0 +1,418 @@ +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. +## +## Get latest from https://github.com/github/gitignore/blob/main/VisualStudio.gitignore + +# User-specific files +*.rsuser +*.suo +*.user +*.userosscache +*.sln.docstates +*.env + +# User-specific files (MonoDevelop/Xamarin Studio) +*.userprefs + +# Mono auto generated files +mono_crash.* + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +[Rr]eleases/ +x64/ +x86/ +[Ww][Ii][Nn]32/ +[Aa][Rr][Mm]/ +[Aa][Rr][Mm]64/ +[Aa][Rr][Mm]64[Ee][Cc]/ +bld/ +[Oo]bj/ +[Oo]ut/ +[Ll]og/ +[Ll]ogs/ + +# Build results on 'Bin' directories +**/[Bb]in/* +# Uncomment if you have tasks that rely on *.refresh files to move binaries +# (https://github.com/github/gitignore/pull/3736) +#!**/[Bb]in/*.refresh + +# Visual Studio 2015/2017 cache/options directory +.vs/ +# Uncomment if you have tasks that create the project's static files in wwwroot +#wwwroot/ + +# Visual Studio 2017 auto generated files +Generated\ Files/ + +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* +*.trx + +# NUnit +*.VisualState.xml +TestResult.xml +nunit-*.xml + +# Approval Tests result files +*.received.* + +# Build Results of an ATL Project +[Dd]ebugPS/ +[Rr]eleasePS/ +dlldata.c + +# Benchmark Results +BenchmarkDotNet.Artifacts/ + +# .NET Core +project.lock.json +project.fragment.lock.json +artifacts/ + +# ASP.NET Scaffolding +ScaffoldingReadMe.txt + +# StyleCop +StyleCopReport.xml + +# Files built by Visual Studio +*_i.c +*_p.c +*_h.h +*.ilk +*.meta +*.obj +*.idb +*.iobj +*.pch +*.pdb +*.ipdb +*.pgc +*.pgd +*.rsp +# but not Directory.Build.rsp, as it configures directory-level build defaults +!Directory.Build.rsp +*.sbr +*.tlb +*.tli +*.tlh +*.tmp +*.tmp_proj +*_wpftmp.csproj +*.log +*.tlog +*.vspscc +*.vssscc +.builds +*.pidb +*.svclog +*.scc + +# Chutzpah Test files +_Chutzpah* + +# Visual C++ cache files +ipch/ +*.aps +*.ncb +*.opendb +*.opensdf +*.sdf +*.cachefile +*.VC.db +*.VC.VC.opendb + +# Visual Studio profiler +*.psess +*.vsp +*.vspx +*.sap + +# Visual Studio Trace Files +*.e2e + +# TFS 2012 Local Workspace +$tf/ + +# Guidance Automation Toolkit +*.gpState + +# ReSharper is a .NET coding add-in +_ReSharper*/ +*.[Rr]e[Ss]harper +*.DotSettings.user + +# TeamCity is a build add-in +_TeamCity* + +# DotCover is a Code Coverage Tool +*.dotCover + +# AxoCover is a Code Coverage Tool +.axoCover/* +!.axoCover/settings.json + +# Coverlet is a free, cross platform Code Coverage Tool +coverage*.json +coverage*.xml +coverage*.info + +# Visual Studio code coverage results +*.coverage +*.coveragexml + +# NCrunch +_NCrunch_* +.NCrunch_* +.*crunch*.local.xml +nCrunchTemp_* + +# MightyMoose +*.mm.* +AutoTest.Net/ + +# Web workbench (sass) +.sass-cache/ + +# Installshield output folder +[Ee]xpress/ + +# DocProject is a documentation generator add-in +DocProject/buildhelp/ +DocProject/Help/*.HxT +DocProject/Help/*.HxC +DocProject/Help/*.hhc +DocProject/Help/*.hhk +DocProject/Help/*.hhp +DocProject/Help/Html2 +DocProject/Help/html + +# Click-Once directory +publish/ + +# Publish Web Output +*.[Pp]ublish.xml +*.azurePubxml +# Note: Comment the next line if you want to checkin your web deploy settings, +# but database connection strings (with potential passwords) will be unencrypted +*.pubxml +*.publishproj + +# Microsoft Azure Web App publish settings. Comment the next line if you want to +# checkin your Azure Web App publish settings, but sensitive information contained +# in these scripts will be unencrypted +PublishScripts/ + +# NuGet Packages +*.nupkg +# NuGet Symbol Packages +*.snupkg +# The packages folder can be ignored because of Package Restore +**/[Pp]ackages/* +# except build/, which is used as an MSBuild target. +!**/[Pp]ackages/build/ +# Uncomment if necessary however generally it will be regenerated when needed +#!**/[Pp]ackages/repositories.config +# NuGet v3's project.json files produces more ignorable files +*.nuget.props +*.nuget.targets + +# Microsoft Azure Build Output +csx/ +*.build.csdef + +# Microsoft Azure Emulator +ecf/ +rcf/ + +# Windows Store app package directories and files +AppPackages/ +BundleArtifacts/ +Package.StoreAssociation.xml +_pkginfo.txt +*.appx +*.appxbundle +*.appxupload + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!?*.[Cc]ache/ + +# Others +ClientBin/ +~$* +*~ +*.dbmdl +*.dbproj.schemaview +*.jfm +*.pfx +*.publishsettings +orleans.codegen.cs + +# Including strong name files can present a security risk +# (https://github.com/github/gitignore/pull/2483#issue-259490424) +#*.snk + +# Since there are multiple workflows, uncomment next line to ignore bower_components +# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) +#bower_components/ + +# RIA/Silverlight projects +Generated_Code/ + +# Backup & report files from converting an old project file +# to a newer Visual Studio version. Backup files are not needed, +# because we have git ;-) +_UpgradeReport_Files/ +Backup*/ +UpgradeLog*.XML +UpgradeLog*.htm +ServiceFabricBackup/ +*.rptproj.bak + +# SQL Server files +*.mdf +*.ldf +*.ndf + +# Business Intelligence projects +*.rdl.data +*.bim.layout +*.bim_*.settings +*.rptproj.rsuser +*- [Bb]ackup.rdl +*- [Bb]ackup ([0-9]).rdl +*- [Bb]ackup ([0-9][0-9]).rdl + +# Microsoft Fakes +FakesAssemblies/ + +# GhostDoc plugin setting file +*.GhostDoc.xml + +# Node.js Tools for Visual Studio +.ntvs_analysis.dat +node_modules/ + +# Visual Studio 6 build log +*.plg + +# Visual Studio 6 workspace options file +*.opt + +# Visual Studio 6 auto-generated workspace file (contains which files were open etc.) +*.vbw + +# Visual Studio 6 auto-generated project file (contains which files were open etc.) +*.vbp + +# Visual Studio 6 workspace and project file (working project files containing files to include in project) +*.dsw +*.dsp + +# Visual Studio 6 technical files +*.ncb +*.aps + +# Visual Studio LightSwitch build output +**/*.HTMLClient/GeneratedArtifacts +**/*.DesktopClient/GeneratedArtifacts +**/*.DesktopClient/ModelManifest.xml +**/*.Server/GeneratedArtifacts +**/*.Server/ModelManifest.xml +_Pvt_Extensions + +# Paket dependency manager +**/.paket/paket.exe +paket-files/ + +# FAKE - F# Make +**/.fake/ + +# CodeRush personal settings +**/.cr/personal + +# Python Tools for Visual Studio (PTVS) +**/__pycache__/ +*.pyc + +# Cake - Uncomment if you are using it +#tools/** +#!tools/packages.config + +# Tabs Studio +*.tss + +# Telerik's JustMock configuration file +*.jmconfig + +# BizTalk build output +*.btp.cs +*.btm.cs +*.odx.cs +*.xsd.cs + +# OpenCover UI analysis results +OpenCover/ + +# Azure Stream Analytics local run output +ASALocalRun/ + +# MSBuild Binary and Structured Log +*.binlog +MSBuild_Logs/ + +# AWS SAM Build and Temporary Artifacts folder +.aws-sam + +# NVidia Nsight GPU debugger configuration file +*.nvuser + +# MFractors (Xamarin productivity tool) working folder +**/.mfractor/ + +# Local History for Visual Studio +**/.localhistory/ + +# Visual Studio History (VSHistory) files +.vshistory/ + +# BeatPulse healthcheck temp database +healthchecksdb + +# Backup folder for Package Reference Convert tool in Visual Studio 2017 +MigrationBackup/ + +# Ionide (cross platform F# VS Code tools) working folder +**/.ionide/ + +# Fody - auto-generated XML schema +FodyWeavers.xsd + +# VS Code files for those working on multiple tools +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +!.vscode/*.code-snippets + +# Local History for Visual Studio Code +.history/ + +# Built Visual Studio Code Extensions +*.vsix + +# Windows Installer files from build outputs +*.cab +*.msi +*.msix +*.msm +*.msp diff --git a/Directory.Build.props b/Directory.Build.props new file mode 100644 index 0000000..abb41cc --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,70 @@ + + + + + net8.0;net9.0;net10.0 + + + latest + enable + enable + + + true + true + true + + + true + $(MSBuildThisFileDirectory)Smtp2Go.NET.snk + + + true + true + true + snupkg + git + + + false + false + false + + + true + $(NoWarn);CS1591 + + + + + Alos Engineering + Alos Engineering + Copyright (c) $([System.DateTime]::Now.Year) Alos Engineering + Apache-2.0 + https://github.com/Alos-no/Smtp2Go.NET + https://github.com/Alos-no/Smtp2Go.NET + README.md + + + true + true + + + true + + + + + + + + diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 0000000..8a81b56 --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,203 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2025 Alos AS + Alos Engineering + https://alos.no/ + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/README.md b/README.md new file mode 100644 index 0000000..4e65d3a --- /dev/null +++ b/README.md @@ -0,0 +1,253 @@ +# Smtp2Go.NET + +[![CI](https://github.com/Alos-no/Smtp2Go.NET/actions/workflows/CI.yml/badge.svg)](https://github.com/Alos-no/Smtp2Go.NET/actions/workflows/CI.yml) +[![NuGet](https://img.shields.io/nuget/v/Smtp2Go.NET?color=27ae60)](https://www.nuget.org/packages/Smtp2Go.NET/) +[![License: Apache-2.0](https://img.shields.io/badge/license-Apache--2.0-27ae60)](LICENSE.txt) + +**Smtp2Go.NET** is a strongly-typed .NET client library for the [SMTP2GO](https://www.smtp2go.com/) transactional email API. It supports sending emails, webhook management, and email statistics with built-in HTTP resilience. + +## Installation + +```bash +dotnet add package Smtp2Go.NET +``` + +## Quick Start + +### Configuration (appsettings.json) + +```json +{ + "Smtp2Go": { + "ApiKey": "api-YOUR-KEY-HERE", + "BaseUrl": "https://api.smtp2go.com/v3/", + "Timeout": "00:00:30" + } +} +``` + +### Registration (Program.cs) + +```csharp +// With HttpClient + resilience pipeline (recommended for production) +builder.Services.AddSmtp2GoWithHttp(builder.Configuration); + +// Or with programmatic configuration +builder.Services.AddSmtp2GoWithHttp(options => +{ + options.ApiKey = "api-YOUR-KEY-HERE"; +}); +``` + +### Sending Email + +```csharp +public class EmailService(ISmtp2GoClient smtp2Go) +{ + public async Task SendWelcomeAsync(string recipientEmail) + { + var request = new EmailSendRequest + { + Sender = "noreply@yourdomain.com", + To = [recipientEmail], + Subject = "Welcome!", + HtmlBody = "

Welcome to our platform

" + }; + + var response = await smtp2Go.SendEmailAsync(request); + // response.Data.Succeeded == 1 + } +} +``` + +### Managing Webhooks + +```csharp +// Create a webhook with Basic Auth (credentials embedded in URL) +var request = new WebhookCreateRequest +{ + WebhookUrl = "https://user:pass@api.yourdomain.com/webhooks/smtp2go", + Events = [WebhookCreateEvent.Delivered, WebhookCreateEvent.Bounce] +}; + +var response = await smtp2Go.Webhooks.CreateAsync(request); + +// List all webhooks +var webhooks = await smtp2Go.Webhooks.ListAsync(); + +// Delete a webhook +await smtp2Go.Webhooks.DeleteAsync(webhookId); +``` + +### Receiving Webhook Callbacks + +SMTP2GO sends HTTP POST requests to your registered webhook URL when email events occur. The `WebhookCallbackPayload` model deserializes the inbound payload: + +```csharp +[HttpPost("webhooks/smtp2go")] +public IActionResult HandleWebhook([FromBody] WebhookCallbackPayload payload) +{ + switch (payload.Event) + { + case WebhookCallbackEvent.Delivered: + logger.LogInformation("Delivered to {Email}", payload.Email); + break; + + case WebhookCallbackEvent.Bounce: + logger.LogWarning("Bounce ({Type}) for {Email}: {Context}", + payload.BounceType, payload.Email, payload.BounceContext); + break; + + case WebhookCallbackEvent.SpamComplaint: + logger.LogWarning("Spam complaint from {Email}", payload.Email); + break; + } + + return Ok(); +} +``` + +#### Webhook Event Types + +SMTP2GO uses different event names for **subscriptions** vs **callback payloads**: + +| Subscription (`WebhookCreateEvent`) | Callback (`WebhookCallbackEvent`) | Description | +|--------------------------------------|-------------------------------------|-------------| +| `Processed` | `Processed` | Email accepted and queued by SMTP2GO | +| `Delivered` | `Delivered` | Email delivered to recipient's mail server | +| `Bounce` | `Bounce` | Email bounced (check `BounceType` for hard/soft) | +| `Open` | `Opened` | Recipient opened the email | +| `Click` | `Clicked` | Recipient clicked a tracked link | +| `Spam` | `SpamComplaint` | Recipient marked the email as spam | +| `Unsubscribe` | `Unsubscribed` | Recipient unsubscribed | +| `Resubscribe` | — | Recipient re-subscribed | +| `Reject` | — | Email rejected before delivery | + +#### Callback Payload Fields + +| Field | Type | Description | +|-------|------|-------------| +| `Event` | `WebhookCallbackEvent` | The event type that triggered this callback | +| `EmailId` | `string?` | SMTP2GO email identifier (correlates with send response) | +| `Email` | `string?` | Recipient email address for this event | +| `Sender` | `string?` | Sender email address | +| `Timestamp` | `int` | Unix timestamp (seconds since epoch) | +| `Hostname` | `string?` | SMTP2GO server that processed the email | +| `RecipientsList` | `string[]?` | All recipients from the original send | +| `BounceType` | `BounceType?` | `Hard` or `Soft` (bounce events only) | +| `BounceContext` | `string?` | SMTP transaction context (bounce events only) | +| `Host` | `string?` | Target mail server host and IP (bounce events only) | +| `ClickUrl` | `string?` | Original URL clicked (click events only) | +| `Link` | `string?` | Tracked link URL (click events only) | + +### Querying Statistics + +```csharp +var request = new EmailSummaryRequest +{ + StartDate = DateOnly.FromDateTime(DateTime.UtcNow.AddDays(-7)), + EndDate = DateOnly.FromDateTime(DateTime.UtcNow) +}; + +var summary = await smtp2Go.Statistics.GetEmailSummaryAsync(request); +``` + +## Features + +- **Email Sending** - Send transactional emails with attachments, CC/BCC, custom headers, and inline images +- **Webhook Management** - Create, list, and delete webhook subscriptions for delivery events +- **Webhook Callbacks** - Strongly-typed models for receiving and processing webhook payloads +- **Email Statistics** - Query email delivery summaries and metrics +- **Built-in Resilience** - Production-grade HTTP pipeline with retry, circuit breaker, rate limiting, and timeouts +- **Strongly Typed** - Full request/response models with XML documentation +- **Source-Generated Logging** - Zero-reflection `[LoggerMessage]` for high-performance diagnostics +- **DI Integration** - First-class `IServiceCollection` registration with `IHttpClientFactory` + +## HTTP Resilience Pipeline + +The `AddSmtp2GoWithHttp` registration includes a production-grade resilience pipeline: + +| Layer | Behavior | +|-------|----------| +| Rate Limiter | Concurrency limiter (20 permits, 50 queue) | +| Total Timeout | Outer timeout (60s) covering all retries | +| Retry | Exponential backoff (max 3 attempts). **POST is not retried** to prevent duplicate sends | +| Circuit Breaker | Opens at 10% failure rate over 30s sampling window | +| Per-Attempt Timeout | Individual request timeout (30s) | + +## Configuration Options + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `ApiKey` | `string` | *required* | SMTP2GO API key | +| `BaseUrl` | `string` | `https://api.smtp2go.com/v3/` | API base URL | +| `Timeout` | `TimeSpan` | `00:00:30` | Default request timeout | + +## Supported Frameworks + +| Framework | Supported | +|-----------|:---------:| +| .NET 8 (LTS) | Yes | +| .NET 9 | Yes | +| .NET 10 (LTS) | Yes | + +All packages are **strong-named** for use in strong-named assemblies. + +## Development + +### Prerequisites + +- .NET 10 SDK +- SMTP2GO account with API keys (for integration tests) + +### Building + +```bash +dotnet build Smtp2Go.NET.slnx +``` + +### Testing + +```bash +# Unit tests (73 tests, no network required) +tests/Smtp2Go.NET.UnitTests/bin/Debug/net10.0/Smtp2Go.NET.UnitTests + +# Integration tests (15 tests, requires API keys configured via user secrets) +tests/Smtp2Go.NET.IntegrationTests/bin/Debug/net10.0/Smtp2Go.NET.IntegrationTests +``` + +> **Note:** xUnit v3 test projects are standalone executables. + +### Configuring Test Secrets + +```bash +cd tests/Smtp2Go.NET.IntegrationTests +dotnet user-secrets set "Smtp2Go:ApiKey:Sandbox" "api-YOUR-SANDBOX-KEY" +dotnet user-secrets set "Smtp2Go:ApiKey:Live" "api-YOUR-LIVE-KEY" +dotnet user-secrets set "Smtp2Go:TestSender" "verified-sender@yourdomain.com" +dotnet user-secrets set "Smtp2Go:TestRecipient" "test@yourmailbox.com" +``` + +Or use the interactive setup script: `pwsh -File scripts/setup-secrets.ps1` + +## Project Structure + +``` +Smtp2Go.NET/ +├── src/Smtp2Go.NET/ # Library source +│ ├── Core/Smtp2GoResource.cs # Base class (shared PostAsync) +│ ├── Models/ # Request/response DTOs +│ │ ├── Email/ # Email send models +│ │ ├── Statistics/ # Statistics query models +│ │ └── Webhooks/ # Webhook CRUD + payload models +│ ├── ISmtp2GoClient.cs # Main client interface +│ ├── Smtp2GoClient.cs # Main client implementation +│ └── ServiceCollectionExtensions.cs # DI registration +└── tests/ + ├── Smtp2Go.NET.UnitTests/ # 73 unit tests (Moq-based) + └── Smtp2Go.NET.IntegrationTests/ # 15 integration tests (live API) +``` + +## License + +This project is licensed under the [Apache 2.0 License](LICENSE.txt). diff --git a/Smtp2Go.NET.pub b/Smtp2Go.NET.pub new file mode 100644 index 0000000000000000000000000000000000000000..775c6fed8367f3c36c64df686e98174972955db2 GIT binary patch literal 160 zcmV;R0AK$ABme*efB*oL000060ssI2Bme+XQ$aBR1ONa50098mj*FMDH48wHYt`RZ z7Zi-SlCv1h{992PCs=m?BUd}D;NptMe=MBgt(8$7K`%O@6sWSe%oq5(9QAHT%!?5p zuv?u#%0JmAig=G?Z6>E_n&E34rE8I&vGePJnM}mH!a*wl literal 0 HcmV?d00001 diff --git a/Smtp2Go.NET.slnx b/Smtp2Go.NET.slnx new file mode 100644 index 0000000..709d006 --- /dev/null +++ b/Smtp2Go.NET.slnx @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/Smtp2Go.NET.snk b/Smtp2Go.NET.snk new file mode 100644 index 0000000000000000000000000000000000000000..94dea30f9e0c157818ae7602bac5b2f9e65d3393 GIT binary patch literal 596 zcmV-a0;~N80ssI2Bme+XQ$aES1ONa50098mj*FMDH48wHYt`RZ7Zi-SlCv1h{992P zCs=m?BUd}D;NptMe=MBgt(8$7K`%O@6sWSe%oq5(9QAHT%!?5puv?u#%0JmAig=G? zZ6>E_n&E34rE8I&vGePJnM}m@ zTF`Tus)_;G7hR@_!l0uoAdc(AWz$z?@qgAjqc`vI|5(nFPx&Cx5u_$rWj)Nc>fRX) zOe&D=Dev-;r54k#pcU(|XRf@J*`P^4*5rS)*+{F2Jp$%jaSoql6Nekhm8Xy0ss-c! zIAc69f~F^H*(b%=ja#VILEY0>Z4mCk-L;5UJ52lWV!f`Dzi5&V?3L|TBUJmKtAt*- z7Z;Xp#w(}HlDS?U7lv+^ZYi#I*(b>qGjH<&_YW$JV9HruC!Qai9K!hW!;d(3)acpeN`jQ~$l9H{4lD-)&!++8 il;JCrGR*$xXZbP#6KadP4b7%;J*$CVC(Q-tDXdg++a>M* literal 0 HcmV?d00001 diff --git a/scripts/setup-secrets.ps1 b/scripts/setup-secrets.ps1 new file mode 100644 index 0000000..1e0bba7 --- /dev/null +++ b/scripts/setup-secrets.ps1 @@ -0,0 +1,113 @@ +# Cross-platform PowerShell script to configure user secrets for Smtp2Go.NET test projects. +# Requires PowerShell Core (pwsh) to be installed. +# To run from the project root: pwsh -File ./scripts/setup-secrets.ps1 + +# --- Configuration --- +$ErrorActionPreference = "Stop" + +# Define paths relative to the script's own location ($PSScriptRoot) to make it robust. +$scriptRoot = $PSScriptRoot +$IntegrationTestProject = Join-Path -Path $scriptRoot -ChildPath "..\tests\Smtp2Go.NET.IntegrationTests\Smtp2Go.NET.IntegrationTests.csproj" + +$projects = @( + $IntegrationTestProject +) + +# Define the secrets with user-friendly prompts. +# Using [ordered] ensures that the prompts appear in the exact order they are defined here. +$secrets = [ordered]@{ + "Smtp2Go:ApiKey:Sandbox" = "Enter your SMTP2GO Sandbox API Key (emails accepted, not delivered):"; + "Smtp2Go:ApiKey:Live" = "Enter your SMTP2GO Live API Key (emails are actually delivered):"; + "Smtp2Go:TestSender" = "Enter the verified sender email address (must be verified on your SMTP2GO account):"; + "Smtp2Go:TestRecipient" = "Enter the test recipient email address for live delivery tests:"; +} + +# Optional secrets that are allowed to be empty. +$optionalSecrets = @() + +# --- Script Body --- +Write-Host "--- Smtp2Go.NET Test Secret Setup ---" -ForegroundColor Yellow +Write-Host "This script will configure the necessary secrets for running integration tests." +Write-Host "The secrets will be stored securely using the .NET user-secrets tool." +Write-Host "" + +# 1. Collect all secrets from the user first to avoid repetitive prompting. +$secretValues = @{} +foreach ($key in $secrets.Keys) { + $prompt = $secrets[$key] + # Determine if the secret is sensitive and should be read securely. + $isSensitive = $key -like "*ApiKey*" -or $key -like "*Password*" -or $key -like "*AuthToken*" + + Write-Host $prompt -ForegroundColor Cyan + + if ($isSensitive) { + $value = Read-Host -AsSecureString + } else { + $value = Read-Host -Prompt $prompt + } + + # Check if the value is empty. + $isEmpty = ($value -is [System.Security.SecureString] -and $value.Length -eq 0) -or + ($value -isnot [System.Security.SecureString] -and [string]::IsNullOrWhiteSpace($value)) + + if ($isEmpty) { + if ($optionalSecrets -contains $key) { + Write-Host " (skipped)" -ForegroundColor DarkGray + continue + } + Write-Error "Input cannot be empty. Aborting." + return + } + + $secretValues[$key] = $value +} + +Write-Host "" +Write-Host "Secrets collected. Now applying to all test projects..." -ForegroundColor Green +Write-Host "" + +# 2. Initialize and set secrets for each project. +foreach ($projectPath in $projects) { + # Verify the project path exists before proceeding. + if (-not (Test-Path -Path $projectPath -PathType Leaf)) { + Write-Warning "Could not find project file at path: $projectPath. Skipping." + continue + } + + Write-Host "Configuring project: $projectPath" -ForegroundColor Magenta + + try { + # Initialize user secrets for the project. This is idempotent. + dotnet user-secrets init --project $projectPath | Out-Null + Write-Host " - Initialized user secrets." + + # Set each secret for the current project. + foreach ($key in $secretValues.Keys) { + $value = $secretValues[$key] + + # Special handling for SecureString to pass it to the command-line tool. + if ($value -is [System.Security.SecureString]) { + # Temporarily convert SecureString to plain text for the CLI command. + $bstr = [System.Runtime.InteropServices.Marshal]::SecureStringToBSTR($value) + $plainTextValue = [System.Runtime.InteropServices.Marshal]::PtrToStringBSTR($bstr) + [System.Runtime.InteropServices.Marshal]::FreeBSTR($bstr) + + dotnet user-secrets set "$key" "$plainTextValue" --project $projectPath | Out-Null + # Clear the plaintext variable immediately for security. + Clear-Variable plainTextValue + } else { + dotnet user-secrets set "$key" "$value" --project $projectPath | Out-Null + } + Write-Host " - Set secret for '$key'." + } + Write-Host "Project configured successfully." -ForegroundColor Green + Write-Host "" + } + catch { + Write-Error "An error occurred while configuring project '$projectPath'." + Write-Error $_.Exception.Message + # Continue to the next project even if one fails. + } +} + +Write-Host "--- Setup Complete ---" -ForegroundColor Yellow diff --git a/src/Smtp2Go.NET/Configuration/ResilienceOptions.cs b/src/Smtp2Go.NET/Configuration/ResilienceOptions.cs new file mode 100644 index 0000000..0b4d9cc --- /dev/null +++ b/src/Smtp2Go.NET/Configuration/ResilienceOptions.cs @@ -0,0 +1,174 @@ +namespace Smtp2Go.NET.Configuration; + +/// +/// Configuration options for HTTP resilience policies (retries, circuit breaker, rate limiting). +/// +/// +/// +/// These options configure the resilience pipeline for HTTP requests, including: +/// +/// Retry policies with exponential backoff +/// Circuit breaker to prevent cascading failures +/// Client-side rate limiting to respect API quotas +/// Timeouts for individual requests and total operation duration +/// +/// +/// +/// Important: SMTP2GO API endpoints use POST for all operations. Since POST is +/// non-idempotent, email send requests are NOT retried by default to prevent duplicate +/// sends. Only transient failures on non-send endpoints are retried. +/// +/// +/// +/// +/// { +/// "Smtp2Go": { +/// "Resilience": { +/// "MaxRetries": 3, +/// "RetryBaseDelay": "00:00:01", +/// "PerAttemptTimeout": "00:00:30", +/// "TotalRequestTimeout": "00:01:00", +/// "RateLimiting": { +/// "IsEnabled": true, +/// "PermitLimit": 20, +/// "QueueLimit": 50 +/// } +/// } +/// } +/// } +/// +/// +public sealed class ResilienceOptions +{ + #region Constants & Statics + + /// The configuration section name. + public const string SectionName = "Resilience"; + + #endregion + + + #region Properties & Fields - Public + + /// + /// Gets or sets the maximum number of retry attempts. Defaults to 3. + /// + /// + /// + /// Only idempotent HTTP methods (GET, HEAD, OPTIONS, TRACE, PUT, DELETE) are retried. + /// POST and PATCH requests are NOT retried to prevent duplicate operations. + /// + /// + public int MaxRetries { get; set; } = 3; + + /// + /// Gets or sets the base delay between retry attempts. Defaults to 1 second. + /// + /// + /// + /// Actual delay uses exponential backoff with jitter: + /// delay = baseDelay * 2^attemptNumber + random jitter + /// + /// + public TimeSpan RetryBaseDelay { get; set; } = TimeSpan.FromSeconds(1); + + /// + /// Gets or sets the timeout for each individual HTTP request attempt. Defaults to 30 seconds. + /// + public TimeSpan PerAttemptTimeout { get; set; } = TimeSpan.FromSeconds(30); + + /// + /// Gets or sets the total timeout covering all retry attempts. Defaults to 60 seconds. + /// + /// + /// + /// This is the outer timeout that covers all retry attempts combined. + /// If this timeout is reached, no more retries will be attempted. + /// + /// + public TimeSpan TotalRequestTimeout { get; set; } = TimeSpan.FromSeconds(60); + + /// + /// Gets or sets the circuit breaker failure threshold. Defaults to 0.1 (10%). + /// + /// + /// + /// When the failure rate exceeds this threshold within the sampling duration, + /// the circuit breaker opens and subsequent requests fail fast. + /// + /// + public double CircuitBreakerFailureThreshold { get; set; } = 0.1; + + /// + /// Gets or sets the circuit breaker sampling duration. Defaults to 30 seconds. + /// + public TimeSpan CircuitBreakerSamplingDuration { get; set; } = TimeSpan.FromSeconds(30); + + /// + /// Gets or sets the minimum throughput required before the circuit breaker can trip. Defaults to 10. + /// + public int CircuitBreakerMinimumThroughput { get; set; } = 10; + + /// + /// Gets or sets the duration the circuit breaker stays open before allowing a test request. Defaults to 30 seconds. + /// + public TimeSpan CircuitBreakerBreakDuration { get; set; } = TimeSpan.FromSeconds(30); + + /// + /// Gets or sets the rate limiting options. + /// + public RateLimitingOptions RateLimiting { get; set; } = new(); + + #endregion +} + + +/// +/// Configuration options for client-side rate limiting. +/// +/// +/// +/// Client-side rate limiting helps prevent hitting server-side rate limits by +/// proactively throttling requests before they're sent. This is especially useful +/// for the SMTP2GO API which has rate limits on email sending. +/// +/// +public sealed class RateLimitingOptions +{ + /// + /// Gets or sets whether rate limiting is enabled. Defaults to true. + /// + public bool IsEnabled { get; set; } = true; + + /// + /// Gets or sets the maximum number of concurrent requests allowed. Defaults to 20. + /// + public int PermitLimit { get; set; } = 20; + + /// + /// Gets or sets the maximum number of requests that can be queued when at the permit limit. Defaults to 50. + /// + public int QueueLimit { get; set; } = 50; + + /// + /// Gets or sets whether to enable proactive throttling based on rate limit response headers. Defaults to true. + /// + /// + /// + /// When enabled, the client will respect RateLimit-Remaining and Retry-After + /// headers from server responses to slow down requests before hitting hard limits. + /// + /// + public bool EnableProactiveThrottling { get; set; } = true; + + /// + /// Gets or sets the quota threshold at which proactive throttling begins. Defaults to 0.1 (10%). + /// + /// + /// + /// When the remaining quota drops below this percentage, requests will be delayed + /// to spread usage more evenly over the quota reset period. + /// + /// + public double QuotaLowThreshold { get; set; } = 0.1; +} diff --git a/src/Smtp2Go.NET/Configuration/Smtp2GoOptions.cs b/src/Smtp2Go.NET/Configuration/Smtp2GoOptions.cs new file mode 100644 index 0000000..c26a07e --- /dev/null +++ b/src/Smtp2Go.NET/Configuration/Smtp2GoOptions.cs @@ -0,0 +1,75 @@ +namespace Smtp2Go.NET.Configuration; + +/// +/// Configuration options for the SMTP2GO API client. +/// +/// +/// +/// Configure these options in your appsettings.json under the "Smtp2Go" section, +/// or programmatically via the extension methods. +/// +/// +/// +/// +/// { +/// "Smtp2Go": { +/// "ApiKey": "api-XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", +/// "BaseUrl": "https://api.smtp2go.com/v3/", +/// "Timeout": "00:00:30", +/// "Resilience": { +/// "MaxRetries": 3, +/// "PerAttemptTimeout": "00:00:30" +/// } +/// } +/// } +/// +/// +public sealed class Smtp2GoOptions +{ + #region Constants & Statics + + /// The configuration section name. + public const string SectionName = "Smtp2Go"; + + /// The default SMTP2GO API base URL. + public const string DefaultBaseUrl = "https://api.smtp2go.com/v3/"; + + #endregion + + + #region Properties & Fields - Public + + /// + /// Gets or sets the SMTP2GO API key. This value must be provided. + /// + /// + /// + /// The API key is sent via the X-Smtp2go-Api-Key header on every request. + /// Obtain an API key from your SMTP2GO dashboard at https://app.smtp2go.com/settings/apikeys. + /// + /// + public string? ApiKey { get; set; } + + /// + /// Gets or sets the SMTP2GO API base URL. Defaults to https://api.smtp2go.com/v3/. + /// + public string BaseUrl { get; set; } = DefaultBaseUrl; + + /// + /// Gets or sets the HTTP request timeout. Defaults to 30 seconds. + /// + public TimeSpan Timeout { get; set; } = TimeSpan.FromSeconds(30); + + /// + /// Gets or sets the HTTP resilience options for API calls. + /// + /// + /// + /// Configure retry policies, circuit breaker, and rate limiting for HTTP clients. + /// These settings apply when the library makes calls to the SMTP2GO API. + /// + /// + public ResilienceOptions Resilience { get; set; } = new(); + + #endregion +} diff --git a/src/Smtp2Go.NET/Configuration/Smtp2GoOptionsValidator.cs b/src/Smtp2Go.NET/Configuration/Smtp2GoOptionsValidator.cs new file mode 100644 index 0000000..23eebda --- /dev/null +++ b/src/Smtp2Go.NET/Configuration/Smtp2GoOptionsValidator.cs @@ -0,0 +1,64 @@ +namespace Smtp2Go.NET.Configuration; + +using Microsoft.Extensions.Options; + +/// +/// Validates to ensure all required configuration is present and valid. +/// This validator is invoked at startup when using ValidateOnStart(), providing immediate feedback +/// for configuration issues rather than waiting for the first API call to fail. +/// +/// +/// +/// The validation errors are designed to be clear and actionable, mentioning the configuration +/// section name explicitly so developers can quickly identify the source of configuration issues. +/// +/// +public sealed class Smtp2GoOptionsValidator : IValidateOptions +{ + #region Methods Impl + + /// + public ValidateOptionsResult Validate(string? name, Smtp2GoOptions options) + { + var failures = new List(); + + // Validate ApiKey is provided. + if (string.IsNullOrWhiteSpace(options.ApiKey)) + { + failures.Add( + $"{Smtp2GoOptions.SectionName}:ApiKey is required. " + + $"Set '{Smtp2GoOptions.SectionName}:ApiKey' in your configuration. " + + "Obtain an API key from https://app.smtp2go.com/settings/apikeys."); + } + + // Validate BaseUrl is a valid absolute URI. + if (string.IsNullOrWhiteSpace(options.BaseUrl)) + { + failures.Add( + $"{Smtp2GoOptions.SectionName}:BaseUrl is required. " + + $"Set '{Smtp2GoOptions.SectionName}:BaseUrl' in your configuration."); + } + else if (!Uri.TryCreate(options.BaseUrl, UriKind.Absolute, out var uri) || + (uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps)) + { + failures.Add( + $"{Smtp2GoOptions.SectionName}:BaseUrl must be a valid HTTP or HTTPS URL. " + + $"Current value: '{options.BaseUrl}'"); + } + + // Validate Timeout is positive. + if (options.Timeout <= TimeSpan.Zero) + { + failures.Add( + $"{Smtp2GoOptions.SectionName}:Timeout must be a positive duration. " + + $"Current value: {options.Timeout}"); + } + + // Return validation result. + return failures.Count > 0 + ? ValidateOptionsResult.Fail(failures) + : ValidateOptionsResult.Success; + } + + #endregion +} diff --git a/src/Smtp2Go.NET/Core/Smtp2GoResource.cs b/src/Smtp2Go.NET/Core/Smtp2GoResource.cs new file mode 100644 index 0000000..a2ceaad --- /dev/null +++ b/src/Smtp2Go.NET/Core/Smtp2GoResource.cs @@ -0,0 +1,205 @@ +namespace Smtp2Go.NET.Core; + +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; +using Exceptions; +using Internal; +using Microsoft.Extensions.Logging; +using Models; + +/// +/// Base class for SMTP2GO API resource clients, providing shared HTTP infrastructure. +/// +/// +/// +/// All SMTP2GO API endpoints use POST requests. This base class provides a single +/// implementation that handles serialization, +/// error parsing, and deserialization — eliminating duplication across sub-clients. +/// +/// +/// Modeled after the Cloudflare.NET.Core.ApiResource pattern, but simplified +/// for the SMTP2GO API which is exclusively POST-based. +/// +/// +internal abstract partial class Smtp2GoResource +{ + #region Properties & Fields - Non-Public + + /// The configured HttpClient for making API requests. + protected readonly HttpClient HttpClient; + + /// + /// The logger for this API resource. Required by the [LoggerMessage] source generator + /// which looks for a field of type in the declaring class. + /// + /// + /// Subclasses that use [LoggerMessage] must declare their own _logger field + /// (pointing to the same instance) because the source generator only inspects the immediate type. + /// + // ReSharper disable once InconsistentNaming — required by LoggerMessage source generator convention. + private readonly ILogger _logger; + + #endregion + + + #region Constructors + + /// + /// Initializes a new instance of the class. + /// + /// The HttpClient to use for requests. + /// The logger for this API resource. + protected Smtp2GoResource(HttpClient httpClient, ILogger logger) + { + HttpClient = httpClient; + _logger = logger; + } + + #endregion + + + #region Methods - Protected (Shared POST Helper) + + /// + /// Sends a POST request to the SMTP2GO API and deserializes the response. + /// + /// The request body type. + /// The response type. + /// The API endpoint (relative to BaseAddress). + /// The request body. + /// The cancellation token. + /// The deserialized response. + /// Thrown when the API returns a non-success response. + protected async Task PostAsync( + string endpoint, + TRequest request, + CancellationToken ct) + where TResponse : class + { + // Serialize and send the request. + using var httpResponse = await HttpClient.PostAsJsonAsync( + endpoint, request, Smtp2GoJsonDefaults.Options, ct).ConfigureAwait(false); + + // Handle non-success HTTP status codes. + if (!httpResponse.IsSuccessStatusCode) + { + var errorBody = await httpResponse.Content.ReadAsStringAsync(ct).ConfigureAwait(false); + var errorMessage = ParseErrorMessage(errorBody); + var requestId = ParseRequestId(errorBody); + + LogApiError(endpoint, (int)httpResponse.StatusCode, errorMessage); + + throw new Smtp2GoApiException( + $"SMTP2GO API request to '{endpoint}' failed with status {(int)httpResponse.StatusCode}: {errorMessage}", + httpResponse.StatusCode, + errorMessage, + requestId); + } + + // Deserialize the response body. + var result = await httpResponse.Content.ReadFromJsonAsync( + Smtp2GoJsonDefaults.Options, ct).ConfigureAwait(false); + + if (result is null) + { + throw new Smtp2GoApiException( + $"SMTP2GO API returned null response for '{endpoint}'.", + httpResponse.StatusCode); + } + + // Check for API-level errors in the response envelope. + if (result is ApiResponse apiResponse && apiResponse.Data is null && httpResponse.StatusCode == HttpStatusCode.OK) + { + // Some SMTP2GO endpoints return 200 with error data — check for these. + LogApiError(endpoint, 200, "Response data is null"); + } + + return result; + } + + #endregion + + + #region Methods - Private (Error Parsing) + + /// + /// Attempts to parse an error message from the SMTP2GO API error response body. + /// + /// The raw response body. + /// The extracted error message, or the raw body if parsing fails. + private static string? ParseErrorMessage(string? responseBody) + { + if (string.IsNullOrWhiteSpace(responseBody)) + { + return null; + } + + try + { + using var doc = JsonDocument.Parse(responseBody); + + // Try "data.error" (common SMTP2GO error format). + if (doc.RootElement.TryGetProperty("data", out var data) && + data.TryGetProperty("error", out var error)) + { + return error.GetString(); + } + + // Try "data.error_code". + if (doc.RootElement.TryGetProperty("data", out var data2) && + data2.TryGetProperty("error_code", out var errorCode)) + { + return errorCode.GetString(); + } + + return responseBody; + } + catch (JsonException) + { + return responseBody; + } + } + + + /// + /// Attempts to parse the request ID from the SMTP2GO API response body. + /// + /// The raw response body. + /// The request ID, or null if not found. + private static string? ParseRequestId(string? responseBody) + { + if (string.IsNullOrWhiteSpace(responseBody)) + { + return null; + } + + try + { + using var doc = JsonDocument.Parse(responseBody); + + if (doc.RootElement.TryGetProperty("request_id", out var requestId)) + { + return requestId.GetString(); + } + + return null; + } + catch (JsonException) + { + return null; + } + } + + #endregion + + + #region Source-Generated Logging + + /// Logs an SMTP2GO API error with endpoint, status code, and error message. + [LoggerMessage(LoggingConstants.EventIds.ApiError, LogLevel.Error, + "SMTP2GO API error on {Endpoint}: HTTP {StatusCode} - {ErrorMessage}")] + private partial void LogApiError(string endpoint, int statusCode, string? errorMessage); + + #endregion +} diff --git a/src/Smtp2Go.NET/Exceptions/Smtp2GoApiException.cs b/src/Smtp2Go.NET/Exceptions/Smtp2GoApiException.cs new file mode 100644 index 0000000..8bf703b --- /dev/null +++ b/src/Smtp2Go.NET/Exceptions/Smtp2GoApiException.cs @@ -0,0 +1,85 @@ +namespace Smtp2Go.NET.Exceptions; + +using System.Net; + +/// +/// Exception thrown when the SMTP2GO API returns an error response. +/// +/// +/// +/// This exception carries context about the failed API call, including the HTTP status code, +/// the API's error message, and the request ID for troubleshooting with SMTP2GO support. +/// +/// +public class Smtp2GoApiException : Smtp2GoException +{ + /// + /// Initializes a new instance of the class. + /// + public Smtp2GoApiException() + { + } + + + /// + /// Initializes a new instance of the class with a specified error message. + /// + /// The message that describes the error. + public Smtp2GoApiException(string message) + : base(message) + { + } + + + /// + /// Initializes a new instance of the class with a specified error message + /// and a reference to the inner exception that caused this exception. + /// + /// The message that describes the error. + /// + /// The exception that is the cause of the current exception, or a null reference if no inner exception is specified. + /// + public Smtp2GoApiException(string message, Exception innerException) + : base(message, innerException) + { + } + + + /// + /// Initializes a new instance of the class + /// with API error context. + /// + /// The message that describes the error. + /// The HTTP status code from the SMTP2GO API response. + /// The error message from the SMTP2GO API response body. + /// The request ID from the SMTP2GO API response for troubleshooting. + /// The inner exception, if any. + public Smtp2GoApiException( + string message, + HttpStatusCode statusCode, + string? errorMessage = null, + string? requestId = null, + Exception? innerException = null) + : base(message, innerException!) + { + StatusCode = statusCode; + ErrorMessage = errorMessage; + RequestId = requestId; + } + + + /// + /// Gets the HTTP status code from the SMTP2GO API response. + /// + public HttpStatusCode? StatusCode { get; } + + /// + /// Gets the error message from the SMTP2GO API response body. + /// + public string? ErrorMessage { get; } + + /// + /// Gets the request ID from the SMTP2GO API response, useful for troubleshooting with SMTP2GO support. + /// + public string? RequestId { get; } +} diff --git a/src/Smtp2Go.NET/Exceptions/Smtp2GoConfigurationException.cs b/src/Smtp2Go.NET/Exceptions/Smtp2GoConfigurationException.cs new file mode 100644 index 0000000..be9b5f2 --- /dev/null +++ b/src/Smtp2Go.NET/Exceptions/Smtp2GoConfigurationException.cs @@ -0,0 +1,81 @@ +namespace Smtp2Go.NET.Exceptions; + +/// +/// Exception thrown when SMTP2GO configuration is invalid or missing. +/// +/// +/// +/// This exception is typically thrown during application startup when options validation fails, +/// or at runtime when a named client configuration cannot be resolved. +/// +/// +public sealed class Smtp2GoConfigurationException : Smtp2GoException +{ + /// + /// Initializes a new instance of the class. + /// + public Smtp2GoConfigurationException() + { + } + + + /// + /// Initializes a new instance of the class with a specified error message. + /// + /// The message that describes the configuration error. + public Smtp2GoConfigurationException(string message) + : base(message) + { + } + + + /// + /// Initializes a new instance of the class with a specified error message + /// and a reference to the inner exception that caused this exception. + /// + /// The message that describes the error. + /// + /// The exception that is the cause of the current exception, or a null reference if no inner exception is specified. + /// + public Smtp2GoConfigurationException(string message, Exception innerException) + : base(message, innerException) + { + } + + + /// + /// Initializes a new instance of the class + /// with the name of the configuration that failed and a list of validation errors. + /// + /// The name of the configuration that failed validation. + /// The list of validation errors. + public Smtp2GoConfigurationException(string configurationName, IReadOnlyList errors) + : base(FormatMessage(configurationName, errors)) + { + ConfigurationName = configurationName; + ValidationErrors = errors; + } + + + /// + /// Gets the name of the configuration that failed validation. + /// + public string? ConfigurationName { get; } + + /// + /// Gets the list of validation errors, if any. + /// + public IReadOnlyList? ValidationErrors { get; } + + + private static string FormatMessage(string configurationName, IReadOnlyList errors) + { + var configPart = string.IsNullOrEmpty(configurationName) + ? "Smtp2Go configuration" + : $"Smtp2Go configuration '{configurationName}'"; + + return errors.Count == 1 + ? $"{configPart} is invalid: {errors[0]}" + : $"{configPart} has {errors.Count} validation errors:\n- " + string.Join("\n- ", errors); + } +} diff --git a/src/Smtp2Go.NET/Exceptions/Smtp2GoException.cs b/src/Smtp2Go.NET/Exceptions/Smtp2GoException.cs new file mode 100644 index 0000000..5c1ffde --- /dev/null +++ b/src/Smtp2Go.NET/Exceptions/Smtp2GoException.cs @@ -0,0 +1,44 @@ +namespace Smtp2Go.NET.Exceptions; + +/// +/// Base exception for all Smtp2Go.NET library errors. +/// +/// +/// +/// This base exception allows callers to catch all library-specific errors +/// while still enabling specific exception handling for derived types. +/// +/// +public class Smtp2GoException : Exception +{ + /// + /// Initializes a new instance of the class. + /// + public Smtp2GoException() + { + } + + + /// + /// Initializes a new instance of the class with a specified error message. + /// + /// The message that describes the error. + public Smtp2GoException(string message) + : base(message) + { + } + + + /// + /// Initializes a new instance of the class with a specified error message + /// and a reference to the inner exception that caused this exception. + /// + /// The message that describes the error. + /// + /// The exception that is the cause of the current exception, or a null reference if no inner exception is specified. + /// + public Smtp2GoException(string message, Exception innerException) + : base(message, innerException) + { + } +} diff --git a/src/Smtp2Go.NET/Http/HttpClientExtensions.cs b/src/Smtp2Go.NET/Http/HttpClientExtensions.cs new file mode 100644 index 0000000..5d693ae --- /dev/null +++ b/src/Smtp2Go.NET/Http/HttpClientExtensions.cs @@ -0,0 +1,280 @@ +namespace Smtp2Go.NET.Http; + +using System.Net; +using System.Threading.RateLimiting; +using Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Http.Resilience; +using Microsoft.Extensions.Options; +using Polly; +using Polly.Retry; +using Polly.Timeout; + +/// +/// Extension methods for configuring HTTP clients with resilience policies. +/// +/// +/// +/// These methods configure HTTP clients with production-ready resilience including: +/// +/// Retry with exponential backoff (idempotent methods only) +/// Circuit breaker to prevent cascading failures +/// Per-attempt and total request timeouts +/// Client-side rate limiting +/// +/// +/// +/// Note: SMTP2GO API uses POST for all endpoints. POST requests are NOT retried +/// to prevent duplicate email sends. This is by design — retrying a POST /email/send +/// could result in the recipient receiving the same email multiple times. +/// +/// +public static class HttpClientExtensions +{ + #region Constants & Statics + + /// + /// The default HTTP client name prefix for named clients. + /// + internal const string HttpClientNamePrefix = "Smtp2GoClient"; + + /// + /// HTTP methods that are safe to retry (idempotent methods). + /// + private static readonly HashSet IdempotentMethods = + [ + HttpMethod.Get, + HttpMethod.Head, + HttpMethod.Options, + HttpMethod.Trace, + HttpMethod.Put, + HttpMethod.Delete + ]; + + #endregion + + + #region Methods - Public + + /// + /// Gets the full HTTP client name for a named client. + /// + /// The client name, or null for the default client. + /// The full HTTP client name. + public static string GetHttpClientName(string? clientName = null) + { + return string.IsNullOrEmpty(clientName) + ? HttpClientNamePrefix + : $"{HttpClientNamePrefix}:{clientName}"; + } + + + /// + /// Adds an HTTP client with resilience policies configured from options. + /// + /// The service collection. + /// Optional client name for named clients. + /// The HTTP client builder for further configuration. + public static IHttpClientBuilder AddSmtp2GoHttpClient( + this IServiceCollection services, + string? clientName = null) + { + var httpClientName = GetHttpClientName(clientName); + + // Create the HTTP client builder. + var builder = services.AddHttpClient(httpClientName); + + // Add the resilience handler to the builder. + // Note: AddResilienceHandler returns IHttpResiliencePipelineBuilder, not IHttpClientBuilder, + // so we call it for its side effect and return the original builder. + AddResilienceHandler(builder, clientName); + + return builder; + } + + #endregion + + + #region Methods - Private + + /// + /// Adds a resilience handler to the HTTP client builder. + /// + /// The HTTP client builder. + /// Optional client name for options resolution. + private static void AddResilienceHandler(IHttpClientBuilder builder, string? clientName) + { + var pipelineName = clientName is null ? "Smtp2GoPipeline" : $"Smtp2GoPipeline:{clientName}"; + + builder.AddResilienceHandler(pipelineName, (pipelineBuilder, context) => + { + // Resolve options at runtime to allow configuration changes. + var options = context.ServiceProvider + .GetRequiredService>() + .Get(clientName ?? Options.DefaultName); + + ConfigureResiliencePipeline(pipelineBuilder, options.Resilience, clientName); + }); + } + + + /// + /// Configures the resilience pipeline with retries, circuit breaker, and rate limiting. + /// + /// The resilience pipeline builder. + /// The resilience options. + /// The client name for logging/metrics. + /// + /// + /// The pipeline order follows Microsoft's recommended standard pipeline: + /// Rate Limiter -> Total Timeout -> Retry -> Circuit Breaker -> Attempt Timeout + /// + /// + /// Ref: https://learn.microsoft.com/en-us/dotnet/core/resilience/http-resilience#standard-pipeline + /// + /// + internal static void ConfigureResiliencePipeline( + ResiliencePipelineBuilder builder, + ResilienceOptions options, + string? clientName = null) + { + var namePrefix = string.IsNullOrEmpty(clientName) ? "Smtp2Go" : $"Smtp2Go:{clientName}"; + + // 1. OUTERMOST: Client-side rate limiting (if enabled). + if (options.RateLimiting.IsEnabled) + { + builder.AddRateLimiter(new HttpRateLimiterStrategyOptions + { + Name = $"{namePrefix}:RateLimiter", + DefaultRateLimiterOptions = new ConcurrencyLimiterOptions + { + PermitLimit = options.RateLimiting.PermitLimit, + QueueLimit = options.RateLimiting.QueueLimit, + QueueProcessingOrder = QueueProcessingOrder.OldestFirst + } + }); + } + + // 2. Total request timeout (outer timeout covering all retries). + builder.AddTimeout(new HttpTimeoutStrategyOptions + { + Name = $"{namePrefix}:TotalTimeout", + Timeout = options.TotalRequestTimeout + }); + + // 3. Retry strategy with exponential backoff. + // Only idempotent methods (GET, PUT, DELETE, etc.) are retried. + // POST requests (all SMTP2GO API calls) are NOT retried to prevent duplicate emails. + if (options.MaxRetries > 0) + { + builder.AddRetry(new HttpRetryStrategyOptions + { + Name = $"{namePrefix}:Retry", + MaxRetryAttempts = options.MaxRetries, + Delay = options.RetryBaseDelay, + BackoffType = DelayBackoffType.Exponential, + UseJitter = true, + ShouldHandle = args => ShouldRetry(args, options) + }); + } + + // 4. Circuit breaker to prevent cascading failures. + builder.AddCircuitBreaker(new HttpCircuitBreakerStrategyOptions + { + Name = $"{namePrefix}:CircuitBreaker", + FailureRatio = options.CircuitBreakerFailureThreshold, + SamplingDuration = options.CircuitBreakerSamplingDuration, + MinimumThroughput = options.CircuitBreakerMinimumThroughput, + BreakDuration = options.CircuitBreakerBreakDuration, + ShouldHandle = args => ValueTask.FromResult(IsTransientFailure(args.Outcome)) + }); + + // 5. INNERMOST: Per-attempt timeout (inner timeout for each request). + builder.AddTimeout(new HttpTimeoutStrategyOptions + { + Name = $"{namePrefix}:AttemptTimeout", + Timeout = options.PerAttemptTimeout + }); + } + + + /// + /// Determines whether a request should be retried. + /// + private static ValueTask ShouldRetry( + RetryPredicateArguments args, + ResilienceOptions options) + { + // Don't retry non-idempotent methods (POST, PATCH) to prevent duplicate operations. + // The request message is available via the response's RequestMessage property. + var method = args.Outcome.Result?.RequestMessage?.Method; + + if (method is not null && !IdempotentMethods.Contains(method)) + { + return new ValueTask(false); + } + + // Retry transient exceptions (network errors, timeouts). + if (args.Outcome.Exception is HttpRequestException or TimeoutRejectedException) + { + return new ValueTask(true); + } + + // Check HTTP response status codes. + if (args.Outcome.Result is not { } response) + { + return new ValueTask(false); + } + + var statusCode = response.StatusCode; + + // Retry on 408 (Request Timeout) and 5xx server errors. + if (statusCode == HttpStatusCode.RequestTimeout || (int)statusCode >= 500) + { + return new ValueTask(true); + } + + // Retry on 429 (Too Many Requests) only when rate limiting is enabled. + if (statusCode == HttpStatusCode.TooManyRequests) + { + return new ValueTask(options.RateLimiting.IsEnabled); + } + + return new ValueTask(false); + } + + + /// + /// Determines whether an outcome represents a transient failure that may succeed on retry. + /// + /// + /// + /// This method is used by the circuit breaker to determine whether a failure should + /// count towards opening the circuit. + /// + /// + private static bool IsTransientFailure(Outcome outcome) + { + // Exception-based failures (network errors, Polly timeouts). + // TimeoutRejectedException is thrown by Polly when the timeout strategy triggers. + if (outcome.Exception is HttpRequestException or TimeoutRejectedException) + { + return true; + } + + // Response-based failures (server errors). + if (outcome.Result is null) + { + return false; + } + + return outcome.Result.StatusCode is + HttpStatusCode.RequestTimeout or // 408 + HttpStatusCode.InternalServerError or // 500 + HttpStatusCode.BadGateway or // 502 + HttpStatusCode.ServiceUnavailable or // 503 + HttpStatusCode.GatewayTimeout; // 504 + } + + #endregion +} diff --git a/src/Smtp2Go.NET/ISmtp2GoClient.cs b/src/Smtp2Go.NET/ISmtp2GoClient.cs new file mode 100644 index 0000000..6837163 --- /dev/null +++ b/src/Smtp2Go.NET/ISmtp2GoClient.cs @@ -0,0 +1,74 @@ +namespace Smtp2Go.NET; + +using Models.Email; + +/// +/// Provides the primary client interface for the SMTP2GO API. +/// +/// +/// +/// This interface defines the contract for interacting with the SMTP2GO v3 API. +/// Implementations are registered via extension methods. +/// +/// +/// Sub-client modules are accessible via properties: +/// +/// — Webhook management (create, list, delete). +/// — Email analytics and delivery metrics. +/// +/// +/// +/// +/// +/// // Inject ISmtp2GoClient via DI +/// public class EmailService(ISmtp2GoClient smtp2Go) +/// { +/// public async Task SendAsync(CancellationToken ct) +/// { +/// var request = new EmailSendRequest +/// { +/// Sender = "noreply@example.com", +/// To = ["user@example.com"], +/// Subject = "Hello", +/// HtmlBody = "<h1>Hello World</h1>" +/// }; +/// +/// var response = await smtp2Go.SendEmailAsync(request, ct); +/// } +/// } +/// +/// +public interface ISmtp2GoClient +{ + /// + /// Gets the webhook management sub-client. + /// + /// + /// + /// Use this property to create, list, and delete webhooks for receiving + /// email event notifications from SMTP2GO. + /// + /// + ISmtp2GoWebhookClient Webhooks { get; } + + /// + /// Gets the statistics and analytics sub-client. + /// + /// + /// + /// Use this property to retrieve email delivery statistics and + /// analytics from the SMTP2GO /stats/* endpoints. + /// + /// + ISmtp2GoStatisticsClient Statistics { get; } + + /// + /// Sends an email via the SMTP2GO API. + /// + /// The email send request containing sender, recipients, subject, and body. + /// The cancellation token. + /// The email send response containing success/failure counts and email ID. + /// Thrown when the SMTP2GO API returns an error. + /// Thrown when the HTTP request fails. + Task SendEmailAsync(EmailSendRequest request, CancellationToken ct = default); +} diff --git a/src/Smtp2Go.NET/ISmtp2GoStatisticsClient.cs b/src/Smtp2Go.NET/ISmtp2GoStatisticsClient.cs new file mode 100644 index 0000000..6faa9f3 --- /dev/null +++ b/src/Smtp2Go.NET/ISmtp2GoStatisticsClient.cs @@ -0,0 +1,39 @@ +namespace Smtp2Go.NET; + +using Models.Statistics; + +/// +/// Provides the statistics sub-client interface for SMTP2GO API analytics endpoints. +/// +/// +/// +/// Access this interface via . +/// The statistics client covers the /stats/* family of SMTP2GO endpoints, +/// providing aggregate email analytics and delivery metrics. +/// +/// +/// +/// +/// // Get email statistics for a date range +/// var stats = await smtp2Go.Statistics.GetEmailSummaryAsync( +/// new EmailSummaryRequest +/// { +/// StartDate = "2025-01-01", +/// EndDate = "2025-01-31" +/// }); +/// +/// +public interface ISmtp2GoStatisticsClient +{ + /// + /// Gets email statistics summary from the SMTP2GO API. + /// + /// Optional request with date range filters. Pass null for default statistics. + /// The cancellation token. + /// The email summary response containing delivery statistics. + /// Thrown when the SMTP2GO API returns an error. + /// Thrown when the HTTP request fails. + Task GetEmailSummaryAsync( + EmailSummaryRequest? request = null, + CancellationToken ct = default); +} diff --git a/src/Smtp2Go.NET/ISmtp2GoWebhookClient.cs b/src/Smtp2Go.NET/ISmtp2GoWebhookClient.cs new file mode 100644 index 0000000..d2c6d24 --- /dev/null +++ b/src/Smtp2Go.NET/ISmtp2GoWebhookClient.cs @@ -0,0 +1,44 @@ +namespace Smtp2Go.NET; + +using Models.Webhooks; + +/// +/// Provides the client interface for SMTP2GO webhook management operations. +/// +/// +/// +/// This sub-client handles webhook lifecycle operations: creating, listing, and deleting +/// webhooks that receive email event notifications from SMTP2GO. +/// +/// +/// Access this interface via . +/// +/// +public interface ISmtp2GoWebhookClient +{ + /// + /// Creates a new webhook subscription. + /// + /// The webhook creation request containing URL, events, and optional authentication. + /// The cancellation token. + /// The webhook creation response containing the new webhook ID. + /// Thrown when the SMTP2GO API returns an error. + Task CreateAsync(WebhookCreateRequest request, CancellationToken ct = default); + + /// + /// Lists all configured webhooks. + /// + /// The cancellation token. + /// The response containing an array of webhook information. + /// Thrown when the SMTP2GO API returns an error. + Task ListAsync(CancellationToken ct = default); + + /// + /// Deletes a webhook by its ID. + /// + /// The ID of the webhook to delete. + /// The cancellation token. + /// The deletion response. + /// Thrown when the SMTP2GO API returns an error. + Task DeleteAsync(int webhookId, CancellationToken ct = default); +} diff --git a/src/Smtp2Go.NET/Internal/LoggingConstants.cs b/src/Smtp2Go.NET/Internal/LoggingConstants.cs new file mode 100644 index 0000000..2a3ca66 --- /dev/null +++ b/src/Smtp2Go.NET/Internal/LoggingConstants.cs @@ -0,0 +1,107 @@ +namespace Smtp2Go.NET.Internal; + +/// +/// Centralized logging category constants for the Smtp2Go.NET library. +/// +/// +/// +/// Using centralized category names ensures: +/// +/// DRY principle - single source of truth for category names +/// Consistent logging across all library components +/// Independent verbosity tuning per component via logging configuration +/// Structured log filtering and analysis +/// +/// +/// +/// +/// +/// // In appsettings.json, configure per-category log levels: +/// { +/// "Logging": { +/// "LogLevel": { +/// "Smtp2Go.NET.Core": "Information", +/// "Smtp2Go.NET.Http": "Warning", +/// "Smtp2Go.NET.Http.Resilience": "Debug" +/// } +/// } +/// } +/// +/// +internal static class LoggingConstants +{ + /// + /// Logging category names for different library components. + /// + public static class Categories + { + /// Core client logging category. + public const string Core = "Smtp2Go.NET.Core"; + + /// HTTP client logging category. + public const string Http = "Smtp2Go.NET.Http"; + + /// HTTP resilience pipeline logging category (retries, circuit breaker, etc.). + public const string HttpResilience = "Smtp2Go.NET.Http.Resilience"; + + /// Configuration and options logging category. + public const string Configuration = "Smtp2Go.NET.Configuration"; + + /// Webhook sub-client logging category. + public const string Webhooks = "Smtp2Go.NET.Webhooks"; + + /// Statistics sub-client logging category. + public const string Statistics = "Smtp2Go.NET.Statistics"; + } + + + /// + /// Event IDs for structured logging. + /// + /// + /// + /// Event IDs enable structured log filtering and alerting. + /// Reserve ranges for different components: + /// + /// 100-199: Email send events + /// 200-299: HTTP client events + /// 300-399: Configuration events + /// 400-499: Webhook events + /// 500-599: Error events + /// + /// + /// + public static class EventIds + { + // Email send events (100-199) + public const int EmailSendStarted = 100; + public const int EmailSendCompleted = 101; + public const int EmailSendFailed = 102; + public const int EmailSummaryRequested = 110; + + // HTTP client events (200-299) + public const int HttpRequestStarted = 200; + public const int HttpRequestCompleted = 201; + public const int HttpRequestFailed = 202; + public const int HttpRetryAttempt = 210; + public const int HttpCircuitBreakerOpened = 220; + public const int HttpCircuitBreakerClosed = 221; + public const int HttpRateLimited = 230; + + // Configuration events (300-399) + public const int ConfigurationLoaded = 300; + public const int ConfigurationValidationFailed = 301; + + // Webhook events (400-499) + public const int WebhookCreateStarted = 400; + public const int WebhookCreateCompleted = 401; + public const int WebhookListRequested = 410; + public const int WebhookDeleteStarted = 420; + public const int WebhookDeleteCompleted = 421; + + // Error events (500-599) + public const int UnexpectedError = 500; + public const int OperationCancelled = 501; + public const int ApiError = 510; + } +} diff --git a/src/Smtp2Go.NET/Internal/Smtp2GoJsonDefaults.cs b/src/Smtp2Go.NET/Internal/Smtp2GoJsonDefaults.cs new file mode 100644 index 0000000..82f8c33 --- /dev/null +++ b/src/Smtp2Go.NET/Internal/Smtp2GoJsonDefaults.cs @@ -0,0 +1,25 @@ +namespace Smtp2Go.NET.Internal; + +using System.Text.Json; +using System.Text.Json.Serialization; + +/// +/// Default JSON serialization options for the SMTP2GO API. +/// +/// +/// +/// The SMTP2GO API uses snake_case naming convention for all JSON properties. +/// Null values are omitted from serialization to keep requests minimal. +/// +/// +internal static class Smtp2GoJsonDefaults +{ + /// + /// Standard JSON options for serializing/deserializing SMTP2GO API payloads. + /// + public static readonly JsonSerializerOptions Options = new() + { + PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull + }; +} diff --git a/src/Smtp2Go.NET/Models/ApiResponse.cs b/src/Smtp2Go.NET/Models/ApiResponse.cs new file mode 100644 index 0000000..0f2a083 --- /dev/null +++ b/src/Smtp2Go.NET/Models/ApiResponse.cs @@ -0,0 +1,53 @@ +namespace Smtp2Go.NET.Models; + +using System.Text.Json.Serialization; + +/// +/// Generic API response envelope for all SMTP2GO API responses. +/// +/// +/// +/// The SMTP2GO API wraps all responses in a standard envelope containing a +/// request_id for troubleshooting and a data object with the +/// response-specific payload. +/// +/// +/// Example JSON: +/// +/// { +/// "request_id": "abc-123", +/// "data": { ... } +/// } +/// +/// +/// +/// +/// The type of the response data payload. Each API endpoint defines its own data shape. +/// +public class ApiResponse +{ + /// + /// Gets the unique request identifier assigned by the SMTP2GO API. + /// + /// + /// + /// This identifier can be used when contacting SMTP2GO support to trace + /// a specific API call. It is returned in every API response. + /// + /// + [JsonPropertyName("request_id")] + public string? RequestId { get; init; } + + /// + /// Gets the response data payload. + /// + /// + /// + /// The structure of the data object varies by endpoint. For example, + /// /email/send returns send results while /stats/email + /// returns summary statistics. + /// + /// + [JsonPropertyName("data")] + public TData? Data { get; init; } +} diff --git a/src/Smtp2Go.NET/Models/Email/Attachment.cs b/src/Smtp2Go.NET/Models/Email/Attachment.cs new file mode 100644 index 0000000..71d93fa --- /dev/null +++ b/src/Smtp2Go.NET/Models/Email/Attachment.cs @@ -0,0 +1,66 @@ +namespace Smtp2Go.NET.Models.Email; + +using System.Text.Json.Serialization; + +/// +/// Represents a file attachment for an outgoing email. +/// +/// +/// +/// Attachments are included in the email send request as Base64-encoded blobs. +/// This model is used for both regular attachments (downloaded by the recipient) +/// and inline attachments (embedded in HTML via cid: references). +/// +/// +/// For inline attachments, the is used as the +/// Content-ID reference in HTML (e.g., <img src="cid:logo.png" />). +/// +/// +/// +/// +/// var attachment = new Attachment +/// { +/// Filename = "report.pdf", +/// Fileblob = Convert.ToBase64String(fileBytes), +/// Mimetype = "application/pdf" +/// }; +/// +/// +public class Attachment +{ + /// + /// Gets or sets the file name of the attachment. + /// + /// + /// + /// The filename as it will appear to the recipient (e.g., "report.pdf"). + /// For inline attachments, this is also the Content-ID used in cid: references. + /// + /// + [JsonPropertyName("filename")] + public required string Filename { get; set; } + + /// + /// Gets or sets the Base64-encoded file content. + /// + /// + /// + /// The raw file bytes must be encoded as a Base64 string before assignment. + /// Use to encode file content. + /// + /// + [JsonPropertyName("fileblob")] + public required string Fileblob { get; set; } + + /// + /// Gets or sets the MIME type of the attachment. + /// + /// + /// + /// The MIME type determines how the recipient's email client handles the file + /// (e.g., "application/pdf", "image/png", "text/csv"). + /// + /// + [JsonPropertyName("mimetype")] + public required string Mimetype { get; set; } +} diff --git a/src/Smtp2Go.NET/Models/Email/CustomHeader.cs b/src/Smtp2Go.NET/Models/Email/CustomHeader.cs new file mode 100644 index 0000000..3394f5f --- /dev/null +++ b/src/Smtp2Go.NET/Models/Email/CustomHeader.cs @@ -0,0 +1,52 @@ +namespace Smtp2Go.NET.Models.Email; + +using System.Text.Json.Serialization; + +/// +/// Represents a custom email header to include in an outgoing message. +/// +/// +/// +/// Custom headers are useful for tracking, categorization, and integration +/// with external systems. Common examples include X-Custom-Tag for +/// analytics grouping and Reply-To for directing replies. +/// +/// +/// Header names should follow RFC 5322 conventions. Custom headers typically +/// use the X- prefix by convention. +/// +/// +/// +/// +/// var header = new CustomHeader +/// { +/// Header = "X-Custom-Tag", +/// Value = "password-reset" +/// }; +/// +/// +public class CustomHeader +{ + /// + /// Gets or sets the header name. + /// + /// + /// + /// The header name (e.g., "X-Custom-Tag", "Reply-To"). + /// Must conform to RFC 5322 header field name syntax. + /// + /// + [JsonPropertyName("header")] + public required string Header { get; set; } + + /// + /// Gets or sets the header value. + /// + /// + /// + /// The header value (e.g., "password-reset", "support@alos.app"). + /// + /// + [JsonPropertyName("value")] + public required string Value { get; set; } +} diff --git a/src/Smtp2Go.NET/Models/Email/EmailSendRequest.cs b/src/Smtp2Go.NET/Models/Email/EmailSendRequest.cs new file mode 100644 index 0000000..2a4c3d3 --- /dev/null +++ b/src/Smtp2Go.NET/Models/Email/EmailSendRequest.cs @@ -0,0 +1,173 @@ +namespace Smtp2Go.NET.Models.Email; + +using System.Text.Json.Serialization; + +/// +/// Request model for the SMTP2GO POST /email/send endpoint. +/// +/// +/// +/// Sends an email through the SMTP2GO API. At minimum, , +/// , and are required. Either +/// or (or both) should be provided +/// unless using a . +/// +/// +/// Attachments are Base64-encoded and included inline in the request body. +/// For inline images referenced via cid: in HTML bodies, use the +/// collection. +/// +/// +public class EmailSendRequest +{ + /// + /// Gets or sets the sender email address. + /// + /// + /// + /// The sender address must be verified in the SMTP2GO account. + /// Supports the format "Display Name <email@example.com>". + /// + /// + /// "Alos Notifications <noreply@alos.app>" + [JsonPropertyName("sender")] + public required string Sender { get; set; } + + /// + /// Gets or sets the primary recipient email addresses. + /// + /// + /// + /// At least one recipient is required. Each entry supports the format + /// "Display Name <email@example.com>". + /// + /// + [JsonPropertyName("to")] + public required string[] To { get; set; } + + /// + /// Gets or sets the email subject line. + /// + [JsonPropertyName("subject")] + public required string Subject { get; set; } + + /// + /// Gets or sets the plain text body of the email. + /// + /// + /// + /// If both and are provided, + /// the email is sent as a multipart/alternative message, allowing the + /// recipient's client to choose the preferred format. + /// + /// + [JsonPropertyName("text_body")] + public string? TextBody { get; set; } + + /// + /// Gets or sets the HTML body of the email. + /// + /// + /// + /// When using inline images, reference them via cid:filename in the HTML + /// and include the corresponding files in the collection. + /// + /// + [JsonPropertyName("html_body")] + public string? HtmlBody { get; set; } + + /// + /// Gets or sets the CC (carbon copy) recipient email addresses. + /// + /// + /// + /// CC recipients receive a copy of the email and are visible to all recipients. + /// Each entry supports the format "Display Name <email@example.com>". + /// + /// + [JsonPropertyName("cc")] + public string[]? Cc { get; set; } + + /// + /// Gets or sets the BCC (blind carbon copy) recipient email addresses. + /// + /// + /// + /// BCC recipients receive a copy of the email but are not visible to other recipients. + /// Each entry supports the format "Display Name <email@example.com>". + /// + /// + [JsonPropertyName("bcc")] + public string[]? Bcc { get; set; } + + /// + /// Gets or sets custom email headers to include in the message. + /// + /// + /// + /// Custom headers are useful for tracking and categorization. For example, + /// X-Custom-Tag headers can be used to group emails in SMTP2GO analytics. + /// + /// + [JsonPropertyName("custom_headers")] + public CustomHeader[]? CustomHeaders { get; set; } + + /// + /// Gets or sets the file attachments to include with the email. + /// + /// + /// + /// Each attachment includes a filename, MIME type, and Base64-encoded content. + /// Attachments are delivered as downloadable files in the recipient's email client. + /// + /// + [JsonPropertyName("attachments")] + public Attachment[]? Attachments { get; set; } + + /// + /// Gets or sets inline attachments for HTML body image references. + /// + /// + /// + /// Inline attachments are referenced in the HTML body via cid:filename. + /// Unlike regular , inline files are embedded within + /// the email body and are not shown as separate downloadable files. + /// + /// + [JsonPropertyName("inlines")] + public Attachment[]? Inlines { get; set; } + + /// + /// Gets or sets the SMTP2GO template identifier to use for this email. + /// + /// + /// + /// When a template ID is specified, the email body is rendered from the template + /// with merge data from . The + /// and properties are ignored when a template is used. + /// + /// + [JsonPropertyName("template_id")] + public string? TemplateId { get; set; } + + /// + /// Gets or sets the template merge data for variable substitution. + /// + /// + /// + /// Used in conjunction with . The keys in this dictionary + /// correspond to merge variables defined in the SMTP2GO template. + /// + /// + /// + /// + /// TemplateData = new Dictionary<string, object> + /// { + /// ["user_name"] = "John Doe", + /// ["verification_url"] = "https://alos.app/verify/abc123" + /// }; + /// + /// + [JsonPropertyName("template_data")] + public Dictionary? TemplateData { get; set; } +} diff --git a/src/Smtp2Go.NET/Models/Email/EmailSendResponse.cs b/src/Smtp2Go.NET/Models/Email/EmailSendResponse.cs new file mode 100644 index 0000000..9d74be4 --- /dev/null +++ b/src/Smtp2Go.NET/Models/Email/EmailSendResponse.cs @@ -0,0 +1,71 @@ +namespace Smtp2Go.NET.Models.Email; + +using System.Text.Json.Serialization; + +/// +/// Response model for the SMTP2GO POST /email/send endpoint. +/// +/// +/// +/// Contains the result of an email send operation, including counts of +/// successful and failed recipients, any failure messages, and the +/// unique email identifier assigned by SMTP2GO. +/// +/// +public class EmailSendResponse : ApiResponse; + +/// +/// Data payload for the email send response. +/// +/// +/// +/// The and counts represent the +/// number of recipients that were successfully accepted or rejected by the +/// SMTP2GO sending infrastructure. A succeeded count does not guarantee +/// final delivery — use webhooks to track delivery status. +/// +/// +public class EmailSendResponseData +{ + /// + /// Gets the number of recipients that were successfully accepted for sending. + /// + /// + /// + /// This indicates the message was accepted by SMTP2GO for delivery, + /// not that it has been delivered to the recipient's inbox. + /// + /// + [JsonPropertyName("succeeded")] + public int Succeeded { get; init; } + + /// + /// Gets the number of recipients that failed to be accepted for sending. + /// + [JsonPropertyName("failed")] + public int Failed { get; init; } + + /// + /// Gets the failure messages for recipients that could not be accepted. + /// + /// + /// + /// Each entry describes why a specific recipient was rejected (e.g., + /// invalid email format, suppressed address). + /// + /// + [JsonPropertyName("failures")] + public string[]? Failures { get; init; } + + /// + /// Gets the unique email identifier assigned by SMTP2GO. + /// + /// + /// + /// This identifier can be used to track the email through SMTP2GO's + /// dashboard, API, and webhook events. + /// + /// + [JsonPropertyName("email_id")] + public string? EmailId { get; init; } +} diff --git a/src/Smtp2Go.NET/Models/Statistics/EmailSummaryRequest.cs b/src/Smtp2Go.NET/Models/Statistics/EmailSummaryRequest.cs new file mode 100644 index 0000000..ac5e1bc --- /dev/null +++ b/src/Smtp2Go.NET/Models/Statistics/EmailSummaryRequest.cs @@ -0,0 +1,40 @@ +namespace Smtp2Go.NET.Models.Statistics; + +using System.Text.Json.Serialization; + +/// +/// Request model for the SMTP2GO POST /stats/email_summary endpoint. +/// +/// +/// +/// Retrieves aggregate email sending statistics for the account, optionally +/// filtered by a date range. If no dates are specified, the API returns +/// statistics for the default period (typically the last 30 days). +/// +/// +public class EmailSummaryRequest +{ + /// + /// Gets or sets the start date for the statistics query. + /// + /// + /// + /// The date must be in yyyy-MM-dd format (e.g., "2024-01-01"). + /// If omitted, the API uses its default start date. + /// + /// + [JsonPropertyName("start_date")] + public string? StartDate { get; set; } + + /// + /// Gets or sets the end date for the statistics query. + /// + /// + /// + /// The date must be in yyyy-MM-dd format (e.g., "2024-12-31"). + /// If omitted, the API uses the current date as the end date. + /// + /// + [JsonPropertyName("end_date")] + public string? EndDate { get; set; } +} diff --git a/src/Smtp2Go.NET/Models/Statistics/EmailSummaryResponse.cs b/src/Smtp2Go.NET/Models/Statistics/EmailSummaryResponse.cs new file mode 100644 index 0000000..7f1a855 --- /dev/null +++ b/src/Smtp2Go.NET/Models/Statistics/EmailSummaryResponse.cs @@ -0,0 +1,159 @@ +namespace Smtp2Go.NET.Models.Statistics; + +using System.Text.Json.Serialization; + +/// +/// Response model for the SMTP2GO POST /stats/email_summary endpoint. +/// +/// +/// +/// Contains aggregate email sending statistics for the requested date range, +/// including delivery, bounce, open, click, and unsubscribe counts. +/// +/// +public class EmailSummaryResponse : ApiResponse; + +/// +/// Data payload for the email summary response. +/// +/// +/// +/// Maps to the SMTP2GO POST /stats/email_summary response which includes +/// billing cycle information, email counts, bounce/spam statistics, and engagement metrics. +/// All counts are nullable to handle cases where the SMTP2GO API does not +/// return a particular statistic. +/// +/// +public class EmailSummaryResponseData +{ + /// + /// Gets the start of the current billing cycle. + /// + [JsonPropertyName("cycle_start")] + public string? CycleStart { get; init; } + + /// + /// Gets the end of the current billing cycle. + /// + [JsonPropertyName("cycle_end")] + public string? CycleEnd { get; init; } + + /// + /// Gets the number of emails used in the current billing cycle. + /// + [JsonPropertyName("cycle_used")] + public int? CycleUsed { get; init; } + + /// + /// Gets the number of emails remaining in the current billing cycle. + /// + [JsonPropertyName("cycle_remaining")] + public int? CycleRemaining { get; init; } + + /// + /// Gets the maximum number of emails allowed in the current billing cycle. + /// + [JsonPropertyName("cycle_max")] + public int? CycleMax { get; init; } + + /// + /// Gets the total number of emails sent during the period. + /// + [JsonPropertyName("email_count")] + public int? Emails { get; init; } + + /// + /// Gets the number of emails rejected before delivery (format/policy violations). + /// + [JsonPropertyName("rejects")] + public int? Rejects { get; init; } + + /// + /// Gets the number of emails rejected due to bounce policies. + /// + [JsonPropertyName("bounce_rejects")] + public int? BounceRejects { get; init; } + + /// + /// Gets the number of hard bounces (permanent delivery failures). + /// + /// + /// + /// Hard bounces indicate permanent delivery failures (e.g., invalid address). + /// + /// + [JsonPropertyName("hardbounces")] + public int? HardBounces { get; init; } + + /// + /// Gets the number of soft bounces (temporary delivery failures). + /// + /// + /// + /// Soft bounces indicate temporary failures (e.g., full mailbox). + /// + /// + [JsonPropertyName("softbounces")] + public int? SoftBounces { get; init; } + + /// + /// Gets the bounce percentage as a string (e.g., "0.00"). + /// + [JsonPropertyName("bounce_percent")] + public string? BouncePercent { get; init; } + + /// + /// Gets the number of emails rejected due to spam policies. + /// + [JsonPropertyName("spam_rejects")] + public int? SpamRejects { get; init; } + + /// + /// Gets the number of emails flagged as spam by recipients. + /// + [JsonPropertyName("spam_emails")] + public int? SpamEmails { get; init; } + + /// + /// Gets the spam percentage as a string (e.g., "0.00"). + /// + [JsonPropertyName("spam_percent")] + public string? SpamPercent { get; init; } + + /// + /// Gets the number of times emails were opened by recipients. + /// + /// + /// + /// Open tracking relies on a tracking pixel embedded in HTML emails. + /// Plain text emails and recipients with image loading disabled will not + /// be counted. + /// + /// + [JsonPropertyName("opens")] + public int? Opens { get; init; } + + /// + /// Gets the number of link clicks tracked in emails. + /// + /// + /// + /// Click tracking requires link rewriting to be enabled in the SMTP2GO account. + /// Each unique link click per recipient is counted. + /// + /// + [JsonPropertyName("clicks")] + public int? Clicks { get; init; } + + /// + /// Gets the number of recipients who unsubscribed via the email's unsubscribe mechanism. + /// + [JsonPropertyName("unsubscribes")] + public int? Unsubscribes { get; init; } + + /// + /// Gets the unsubscribe percentage as a string (e.g., "0.00"). + /// + [JsonPropertyName("unsubscribe_percent")] + public string? UnsubscribePercent { get; init; } +} diff --git a/src/Smtp2Go.NET/Models/Webhooks/BounceType.cs b/src/Smtp2Go.NET/Models/Webhooks/BounceType.cs new file mode 100644 index 0000000..5ef734c --- /dev/null +++ b/src/Smtp2Go.NET/Models/Webhooks/BounceType.cs @@ -0,0 +1,207 @@ +namespace Smtp2Go.NET.Models.Webhooks; + +using System.Text.Json; +using System.Text.Json.Serialization; + +/// +/// Defines the types of email bounces reported by SMTP2GO. +/// +/// +/// +/// Bounce classification determines how the recipient address should be handled: +/// +/// — Permanent failure; remove the address from mailing lists. +/// — Temporary failure; the address may be retried later. +/// +/// The SMTP2GO API transmits these as lowercase strings ("hard", "soft"); +/// the handles conversion. +/// +/// +public enum BounceType +{ + /// + /// An unrecognized or unmapped bounce type. + /// + /// + /// + /// Used as a fallback when the API returns a bounce type not yet + /// defined in this enum. Consumers should log and handle gracefully. + /// + /// + Unknown = 0, + + /// + /// A permanent delivery failure (hard bounce). + /// + /// + /// + /// Hard bounces indicate the email address is permanently undeliverable. + /// Common causes include: invalid address, non-existent domain, or + /// permanently rejected sender. The address should be suppressed from + /// all future mailings. + /// + /// + Hard, + + /// + /// A temporary delivery failure (soft bounce). + /// + /// + /// + /// Soft bounces indicate a temporary issue that may resolve on its own. + /// Common causes include: full mailbox, server temporarily unavailable, + /// message too large, or greylisting. SMTP2GO may automatically retry. + /// + /// + Soft +} + + +/// +/// JSON converter for that handles SMTP2GO's +/// lowercase string representation. +/// +/// +/// +/// The SMTP2GO API uses lowercase strings for bounce types: +/// +/// "hard" -> +/// "soft" -> +/// +/// Unrecognized values are deserialized as . +/// +/// +public class BounceTypeJsonConverter : JsonConverter +{ + #region Constants & Statics + + /// + /// The SMTP2GO API string for hard bounces. + /// + private const string HardValue = "hard"; + + /// + /// The SMTP2GO API string for soft bounces. + /// + private const string SoftValue = "soft"; + + #endregion + + + #region Methods - Public + + /// + /// Reads and converts a JSON string to a value. + /// + /// The JSON reader. + /// The type to convert. + /// The serializer options. + /// The deserialized value. + public override BounceType Read( + ref Utf8JsonReader reader, + Type typeToConvert, + JsonSerializerOptions options) + { + var value = reader.GetString(); + + return value switch + { + HardValue => BounceType.Hard, + SoftValue => BounceType.Soft, + _ => BounceType.Unknown + }; + } + + /// + /// Writes a value as a JSON lowercase string. + /// + /// The JSON writer. + /// The value to write. + /// The serializer options. + public override void Write( + Utf8JsonWriter writer, + BounceType value, + JsonSerializerOptions options) + { + var stringValue = value switch + { + BounceType.Hard => HardValue, + BounceType.Soft => SoftValue, + _ => "unknown" + }; + + writer.WriteStringValue(stringValue); + } + + #endregion +} + + +/// +/// JSON converter for nullable that handles SMTP2GO's +/// lowercase string representation and JSON null values. +/// +/// +/// +/// This converter extends to support +/// nullable properties. JSON null values are +/// deserialized as C# null rather than . +/// +/// +public class NullableBounceTypeJsonConverter : JsonConverter +{ + #region Properties & Fields - Non-Public + + /// + /// The inner converter for non-nullable values. + /// + private readonly BounceTypeJsonConverter _inner = new(); + + #endregion + + + #region Methods - Public + + /// + /// Reads and converts a JSON string or null to a nullable value. + /// + /// The JSON reader. + /// The type to convert. + /// The serializer options. + /// The deserialized nullable value, or null. + public override BounceType? Read( + ref Utf8JsonReader reader, + Type typeToConvert, + JsonSerializerOptions options) + { + if (reader.TokenType == JsonTokenType.Null) + { + return null; + } + + return _inner.Read(ref reader, typeof(BounceType), options); + } + + /// + /// Writes a nullable value as a JSON string or null. + /// + /// The JSON writer. + /// The nullable value to write. + /// The serializer options. + public override void Write( + Utf8JsonWriter writer, + BounceType? value, + JsonSerializerOptions options) + { + if (value is null) + { + writer.WriteNullValue(); + + return; + } + + _inner.Write(writer, value.Value, options); + } + + #endregion +} diff --git a/src/Smtp2Go.NET/Models/Webhooks/WebhookCallbackEvent.cs b/src/Smtp2Go.NET/Models/Webhooks/WebhookCallbackEvent.cs new file mode 100644 index 0000000..8599ce0 --- /dev/null +++ b/src/Smtp2Go.NET/Models/Webhooks/WebhookCallbackEvent.cs @@ -0,0 +1,201 @@ +namespace Smtp2Go.NET.Models.Webhooks; + +using System.Text.Json; +using System.Text.Json.Serialization; + +/// +/// Defines the event types returned in SMTP2GO webhook callback payloads. +/// +/// +/// +/// These are callback-level event names — received in +/// when SMTP2GO delivers a webhook +/// POST to the registered URL. +/// +/// +/// Important: Callback event names differ from subscription event +/// names (). For example, subscribing to +/// produces callbacks with +/// ("opened"). +/// +/// +/// The SMTP2GO API transmits these as snake_case strings (e.g., +/// "spam_complaint"); the +/// handles conversion. +/// +/// +public enum WebhookCallbackEvent +{ + /// + /// An unrecognized or unmapped event type. + /// + /// + /// + /// Used as a fallback when the API returns an event type not yet + /// defined in this enum. Consumers should log and handle gracefully. + /// + /// + Unknown = 0, + + /// + /// The email was accepted and queued for delivery by SMTP2GO. + /// + Processed, + + /// + /// The email was successfully delivered to the recipient's mail server. + /// + Delivered, + + /// + /// The email bounced (hard or soft). Use + /// to distinguish between and + /// . + /// + /// + /// + /// SMTP2GO sends "event": "bounce" with a separate "bounce" field + /// containing "hard" or "soft". The bounce diagnostic message is in + /// the "context" field. + /// + /// + Bounce, + + /// + /// The recipient opened the email. + /// + /// + /// + /// Open tracking relies on a tracking pixel and may not capture all opens + /// (e.g., plain text readers, image blocking). + /// + /// + Opened, + + /// + /// The recipient clicked a tracked link in the email. + /// + Clicked, + + /// + /// The recipient unsubscribed via the email's unsubscribe mechanism. + /// + Unsubscribed, + + /// + /// The recipient marked the email as spam/junk. + /// + /// + /// + /// Spam complaints can negatively impact sender reputation. The recipient + /// address should be immediately suppressed from future mailings. + /// + /// + SpamComplaint +} + + +/// +/// JSON converter for that handles SMTP2GO's +/// snake_case string representation in webhook callback payloads. +/// +/// +/// +/// The SMTP2GO API uses snake_case strings for callback event types: +/// +/// "processed" -> +/// "delivered" -> +/// "bounce" -> +/// "opened" -> +/// "clicked" -> +/// "unsubscribed" -> +/// "spam_complaint" -> +/// +/// Unrecognized values are deserialized as . +/// +/// +public class WebhookCallbackEventJsonConverter : JsonConverter +{ + #region Constants & Statics + + /// SMTP2GO callback payload string for the "processed" event. + private const string ProcessedValue = "processed"; + + /// SMTP2GO callback payload string for the "delivered" event. + private const string DeliveredValue = "delivered"; + + /// SMTP2GO callback payload string for the "bounce" event. + private const string BounceValue = "bounce"; + + /// SMTP2GO callback payload string for the "opened" event. + private const string OpenedValue = "opened"; + + /// SMTP2GO callback payload string for the "clicked" event. + private const string ClickedValue = "clicked"; + + /// SMTP2GO callback payload string for the "unsubscribed" event. + private const string UnsubscribedValue = "unsubscribed"; + + /// SMTP2GO callback payload string for the "spam_complaint" event. + private const string SpamComplaintValue = "spam_complaint"; + + #endregion + + + #region Methods - Public + + /// + /// Reads and converts a JSON string to a value. + /// + /// The JSON reader. + /// The type to convert. + /// The serializer options. + /// The deserialized value. + public override WebhookCallbackEvent Read( + ref Utf8JsonReader reader, + Type typeToConvert, + JsonSerializerOptions options) + { + var value = reader.GetString(); + + return value switch + { + ProcessedValue => WebhookCallbackEvent.Processed, + DeliveredValue => WebhookCallbackEvent.Delivered, + BounceValue => WebhookCallbackEvent.Bounce, + OpenedValue => WebhookCallbackEvent.Opened, + ClickedValue => WebhookCallbackEvent.Clicked, + UnsubscribedValue => WebhookCallbackEvent.Unsubscribed, + SpamComplaintValue => WebhookCallbackEvent.SpamComplaint, + _ => WebhookCallbackEvent.Unknown + }; + } + + /// + /// Writes a value as a JSON snake_case string. + /// + /// The JSON writer. + /// The value to write. + /// The serializer options. + public override void Write( + Utf8JsonWriter writer, + WebhookCallbackEvent value, + JsonSerializerOptions options) + { + var stringValue = value switch + { + WebhookCallbackEvent.Processed => ProcessedValue, + WebhookCallbackEvent.Delivered => DeliveredValue, + WebhookCallbackEvent.Bounce => BounceValue, + WebhookCallbackEvent.Opened => OpenedValue, + WebhookCallbackEvent.Clicked => ClickedValue, + WebhookCallbackEvent.Unsubscribed => UnsubscribedValue, + WebhookCallbackEvent.SpamComplaint => SpamComplaintValue, + _ => "unknown" + }; + + writer.WriteStringValue(stringValue); + } + + #endregion +} diff --git a/src/Smtp2Go.NET/Models/Webhooks/WebhookCallbackPayload.cs b/src/Smtp2Go.NET/Models/Webhooks/WebhookCallbackPayload.cs new file mode 100644 index 0000000..c112bef --- /dev/null +++ b/src/Smtp2Go.NET/Models/Webhooks/WebhookCallbackPayload.cs @@ -0,0 +1,182 @@ +namespace Smtp2Go.NET.Models.Webhooks; + +using System.Text.Json.Serialization; + +/// +/// Represents the payload received from an SMTP2GO webhook callback. +/// +/// +/// +/// SMTP2GO sends HTTP POST requests to registered webhook URLs when email +/// events occur. This model deserializes the inbound webhook payload. +/// +/// +/// The fields populated depend on the event type: +/// +/// , , and are only present for bounce events. +/// and are only present for click events. +/// +/// +/// +/// +/// +/// // In an ASP.NET Core controller: +/// [HttpPost("webhooks/smtp2go")] +/// public IActionResult HandleWebhook([FromBody] WebhookCallbackPayload payload) +/// { +/// switch (payload.Event) +/// { +/// case WebhookCallbackEvent.Delivered: +/// // Handle delivery confirmation +/// break; +/// case WebhookCallbackEvent.Bounce: +/// // Handle bounce — check payload.BounceType for hard/soft +/// break; +/// } +/// return Ok(); +/// } +/// +/// +public class WebhookCallbackPayload +{ + /// + /// Gets the hostname of the SMTP2GO sending server that processed the email. + /// + [JsonPropertyName("hostname")] + public string? Hostname { get; init; } + + /// + /// Gets the unique SMTP2GO identifier for the email associated with this event. + /// + /// + /// + /// This corresponds to the email_id returned by the + /// /email/send endpoint and can be used to correlate webhook + /// events with sent emails. + /// + /// + [JsonPropertyName("email_id")] + public string? EmailId { get; init; } + + /// + /// Gets the type of event that triggered this webhook callback. + /// + /// + /// + /// The event type determines which additional fields are populated + /// in the payload. See for all possible values. + /// + /// + [JsonPropertyName("event")] + [JsonConverter(typeof(WebhookCallbackEventJsonConverter))] + public WebhookCallbackEvent Event { get; init; } + + /// + /// Gets the Unix timestamp (seconds since epoch) when the event occurred. + /// + /// + /// + /// Convert to using + /// . + /// + /// + [JsonPropertyName("timestamp")] + public int Timestamp { get; init; } + + /// + /// Gets the recipient email address associated with this event. + /// + /// + /// + /// The specific recipient that this event applies to. For example, + /// a delivered event for a multi-recipient email will generate one + /// webhook per recipient. + /// + /// + [JsonPropertyName("email")] + public string? Email { get; init; } + + /// + /// Gets the sender email address of the original email. + /// + [JsonPropertyName("sender")] + public string? Sender { get; init; } + + /// + /// Gets the list of all recipients of the original email. + /// + /// + /// + /// Contains all To, CC, and BCC recipients from the original send request. + /// + /// + [JsonPropertyName("recipients_list")] + public string[]? RecipientsList { get; init; } + + /// + /// Gets the bounce type when the event is a bounce. + /// + /// + /// + /// Only populated for events. + /// indicates a permanent delivery + /// failure; indicates a temporary failure. + /// + /// + /// SMTP2GO sends the bounce type as a separate "bounce" JSON field + /// (value: "hard" or "soft"), distinct from the "event": "bounce" field. + /// + /// + [JsonPropertyName("bounce")] + [JsonConverter(typeof(BounceTypeJsonConverter))] + public BounceType? BounceType { get; init; } + + /// + /// Gets the bounce diagnostic context from the recipient's mail server. + /// + /// + /// + /// Only populated for events. Contains + /// the SMTP transaction context (e.g., "RCPT TO:<user@example.com>"). + /// + /// + [JsonPropertyName("context")] + public string? BounceContext { get; init; } + + /// + /// Gets the mail server host that the email was delivered to (or bounced from). + /// + /// + /// + /// Only populated for events. Contains the + /// MX host and IP address (e.g., "gmail-smtp-in.l.google.com [209.85.233.26]"). + /// + /// + [JsonPropertyName("host")] + public string? Host { get; init; } + + /// + /// Gets the URL that was clicked by the recipient. + /// + /// + /// + /// Only populated for events. + /// Contains the original URL (before SMTP2GO tracking redirect). + /// + /// + [JsonPropertyName("click_url")] + public string? ClickUrl { get; init; } + + /// + /// Gets the tracked link URL associated with the click event. + /// + /// + /// + /// Only populated for events. + /// This may be the SMTP2GO tracking URL or the original link, + /// depending on the webhook configuration. + /// + /// + [JsonPropertyName("link")] + public string? Link { get; init; } +} diff --git a/src/Smtp2Go.NET/Models/Webhooks/WebhookCreateEvent.cs b/src/Smtp2Go.NET/Models/Webhooks/WebhookCreateEvent.cs new file mode 100644 index 0000000..6e668e3 --- /dev/null +++ b/src/Smtp2Go.NET/Models/Webhooks/WebhookCreateEvent.cs @@ -0,0 +1,238 @@ +namespace Smtp2Go.NET.Models.Webhooks; + +using System.Text.Json; +using System.Text.Json.Serialization; + +/// +/// Defines the event types that can be subscribed to when creating an SMTP2GO webhook. +/// +/// +/// +/// These are subscription-level event names — used in +/// when registering a webhook via the +/// SMTP2GO webhook/add API endpoint. +/// +/// +/// Important: Subscription event names differ from the event names +/// returned in webhook callback payloads (). +/// For example, subscribing to produces callback payloads with +/// and a separate +/// field for hard/soft classification. +/// +/// +/// Using an incorrect event name in the subscription is silently ignored +/// by SMTP2GO — no error is returned, and no webhook callbacks are delivered for that event. +/// +/// +/// +/// +/// var request = new WebhookCreateRequest +/// { +/// WebhookUrl = "https://user:pass@api.alos.app/webhooks/smtp2go", +/// Events = +/// [ +/// WebhookCreateEvent.Delivered, +/// WebhookCreateEvent.Bounce, +/// WebhookCreateEvent.Spam +/// ] +/// }; +/// +/// +[JsonConverter(typeof(WebhookCreateEventJsonConverter))] +public enum WebhookCreateEvent +{ + /// + /// The email was accepted and queued for delivery by SMTP2GO. + /// + Processed, + + /// + /// The email was successfully delivered to the recipient's mail server. + /// + Delivered, + + /// + /// The email bounced (hard or soft). + /// + /// + /// + /// Subscribing to this event produces callback payloads with + /// . Use + /// to distinguish from + /// . + /// + /// + Bounce, + + /// + /// The recipient opened the email. + /// + /// + /// + /// Subscription name: "open".
+ /// Callback payload event: ("opened"). + ///
+ ///
+ Open, + + /// + /// The recipient clicked a tracked link in the email. + /// + /// + /// + /// Subscription name: "click".
+ /// Callback payload event: ("clicked"). + ///
+ ///
+ Click, + + /// + /// The recipient marked the email as spam/junk. + /// + /// + /// + /// Subscription name: "spam".
+ /// Callback payload event: ("spam_complaint"). + ///
+ ///
+ Spam, + + /// + /// The recipient unsubscribed via the email's unsubscribe mechanism. + /// + /// + /// + /// Subscription name: "unsubscribe".
+ /// Callback payload event: ("unsubscribed"). + ///
+ ///
+ Unsubscribe, + + /// + /// The recipient re-subscribed after a previous unsubscribe. + /// + Resubscribe, + + /// + /// The email was rejected by SMTP2GO before delivery. + /// + Reject +} + + +/// +/// JSON converter for that handles SMTP2GO's +/// subscription-level event name strings. +/// +/// +/// +/// The SMTP2GO webhook/add API expects lowercase event names: +/// +/// "processed" -> +/// "delivered" -> +/// "bounce" -> +/// "open" -> +/// "click" -> +/// "spam" -> +/// "unsubscribe" -> +/// "resubscribe" -> +/// "reject" -> +/// +/// +/// +public class WebhookCreateEventJsonConverter : JsonConverter +{ + #region Constants & Statics + + /// SMTP2GO API string for the "processed" subscription event. + private const string ProcessedValue = "processed"; + + /// SMTP2GO API string for the "delivered" subscription event. + private const string DeliveredValue = "delivered"; + + /// SMTP2GO API string for the "bounce" subscription event. + private const string BounceValue = "bounce"; + + /// SMTP2GO API string for the "open" subscription event. + private const string OpenValue = "open"; + + /// SMTP2GO API string for the "click" subscription event. + private const string ClickValue = "click"; + + /// SMTP2GO API string for the "spam" subscription event. + private const string SpamValue = "spam"; + + /// SMTP2GO API string for the "unsubscribe" subscription event. + private const string UnsubscribeValue = "unsubscribe"; + + /// SMTP2GO API string for the "resubscribe" subscription event. + private const string ResubscribeValue = "resubscribe"; + + /// SMTP2GO API string for the "reject" subscription event. + private const string RejectValue = "reject"; + + #endregion + + + #region Methods - Public + + /// + /// Reads and converts a JSON string to a value. + /// + /// The JSON reader. + /// The type to convert. + /// The serializer options. + /// The deserialized value. + /// Thrown when the JSON string is not a recognized subscription event. + public override WebhookCreateEvent Read( + ref Utf8JsonReader reader, + Type typeToConvert, + JsonSerializerOptions options) + { + var value = reader.GetString(); + + return value switch + { + ProcessedValue => WebhookCreateEvent.Processed, + DeliveredValue => WebhookCreateEvent.Delivered, + BounceValue => WebhookCreateEvent.Bounce, + OpenValue => WebhookCreateEvent.Open, + ClickValue => WebhookCreateEvent.Click, + SpamValue => WebhookCreateEvent.Spam, + UnsubscribeValue => WebhookCreateEvent.Unsubscribe, + ResubscribeValue => WebhookCreateEvent.Resubscribe, + RejectValue => WebhookCreateEvent.Reject, + _ => throw new JsonException($"Unknown SMTP2GO subscription event: '{value}'.") + }; + } + + /// + /// Writes a value as a JSON string. + /// + /// The JSON writer. + /// The value to write. + /// The serializer options. + public override void Write( + Utf8JsonWriter writer, + WebhookCreateEvent value, + JsonSerializerOptions options) + { + var stringValue = value switch + { + WebhookCreateEvent.Processed => ProcessedValue, + WebhookCreateEvent.Delivered => DeliveredValue, + WebhookCreateEvent.Bounce => BounceValue, + WebhookCreateEvent.Open => OpenValue, + WebhookCreateEvent.Click => ClickValue, + WebhookCreateEvent.Spam => SpamValue, + WebhookCreateEvent.Unsubscribe => UnsubscribeValue, + WebhookCreateEvent.Resubscribe => ResubscribeValue, + WebhookCreateEvent.Reject => RejectValue, + _ => throw new JsonException($"Unknown WebhookCreateEvent value: '{value}'.") + }; + + writer.WriteStringValue(stringValue); + } + + #endregion +} diff --git a/src/Smtp2Go.NET/Models/Webhooks/WebhookCreateRequest.cs b/src/Smtp2Go.NET/Models/Webhooks/WebhookCreateRequest.cs new file mode 100644 index 0000000..9fc9950 --- /dev/null +++ b/src/Smtp2Go.NET/Models/Webhooks/WebhookCreateRequest.cs @@ -0,0 +1,98 @@ +namespace Smtp2Go.NET.Models.Webhooks; + +using System.Text.Json.Serialization; + +/// +/// Request model for the SMTP2GO webhook creation endpoint. +/// +/// +/// +/// Creates a new webhook subscription that will receive HTTP POST callbacks +/// when the specified email events occur. Webhooks enable real-time notification +/// of delivery status changes without polling the SMTP2GO API. +/// +/// +/// Use the enum for the array +/// to ensure only valid subscription-level event names are used. +/// +/// +/// Webhook Authentication: To require Basic Auth on webhook callbacks, +/// embed credentials in the URL using RFC 3986 userinfo syntax: +/// https://username:password@host/path. SMTP2GO extracts the credentials +/// and sends them as an Authorization: Basic header when delivering callbacks. +/// +/// +/// +/// +/// var request = new WebhookCreateRequest +/// { +/// // Embed Basic Auth credentials directly in the URL. +/// WebhookUrl = "https://webhook-user:secure-password@api.alos.app/webhooks/smtp2go", +/// Events = [WebhookCreateEvent.Delivered, WebhookCreateEvent.Bounce] +/// }; +/// +/// +public class WebhookCreateRequest +{ + /// + /// Gets or sets the URL that will receive webhook event callbacks. + /// + /// + /// + /// The URL must be publicly accessible and accept HTTP POST requests. + /// SMTP2GO will send JSON payloads to this URL when subscribed events occur. + /// HTTPS is strongly recommended for production use. + /// + /// + /// To require Basic Auth on callbacks, embed credentials in the URL: + /// https://username:password@host/path. SMTP2GO extracts the userinfo + /// component and sends it as an Authorization: Basic header. + /// + /// + [JsonPropertyName("url")] + public required string WebhookUrl { get; set; } + + /// + /// Gets or sets the event types to subscribe to. + /// + /// + /// + /// Use enum values (e.g., + /// , + /// ). + /// If null or empty, the webhook may receive all event types depending + /// on the SMTP2GO API default behavior. + /// + /// + /// Warning: SMTP2GO silently ignores unrecognized event names. + /// Using the enum prevents this class of error. + /// + /// + [JsonPropertyName("events")] + public WebhookCreateEvent[]? Events { get; set; } + + /// + /// Gets or sets the sender usernames to filter webhook events by. + /// + /// + /// + /// When specified, the webhook will only fire for emails sent by the + /// listed SMTP2GO sender usernames. If null, the webhook fires for + /// all senders in the account. + /// + /// + [JsonPropertyName("usernames")] + public string[]? Usernames { get; set; } + + /// + /// Gets or sets the output format for webhook payloads. + /// + /// + /// + /// Controls the format of the webhook payload sent to the callback URL. + /// Typically left null to use the SMTP2GO default JSON format. + /// + /// + [JsonPropertyName("output")] + public string? Output { get; set; } +} diff --git a/src/Smtp2Go.NET/Models/Webhooks/WebhookCreateResponse.cs b/src/Smtp2Go.NET/Models/Webhooks/WebhookCreateResponse.cs new file mode 100644 index 0000000..bba885d --- /dev/null +++ b/src/Smtp2Go.NET/Models/Webhooks/WebhookCreateResponse.cs @@ -0,0 +1,33 @@ +namespace Smtp2Go.NET.Models.Webhooks; + +using System.Text.Json.Serialization; + +/// +/// Response model for the SMTP2GO webhook creation endpoint. +/// +/// +/// +/// Contains the unique identifier assigned to the newly created webhook. +/// This identifier is required for subsequent webhook management operations +/// (e.g., deletion). +/// +/// +public class WebhookCreateResponse : ApiResponse; + +/// +/// Data payload for the webhook creation response. +/// +public class WebhookCreateResponseData +{ + /// + /// Gets the unique identifier assigned to the newly created webhook. + /// + /// + /// + /// Store this identifier to manage the webhook later (e.g., deleting it + /// when it is no longer needed). + /// + /// + [JsonPropertyName("id")] + public int? WebhookId { get; init; } +} diff --git a/src/Smtp2Go.NET/Models/Webhooks/WebhookDeleteResponse.cs b/src/Smtp2Go.NET/Models/Webhooks/WebhookDeleteResponse.cs new file mode 100644 index 0000000..88c94bf --- /dev/null +++ b/src/Smtp2Go.NET/Models/Webhooks/WebhookDeleteResponse.cs @@ -0,0 +1,30 @@ +namespace Smtp2Go.NET.Models.Webhooks; + +using System.Text.Json.Serialization; + +/// +/// Response model for the SMTP2GO webhook deletion endpoint. +/// +/// +/// +/// The webhook deletion response is a simple envelope containing only +/// the request identifier. A successful HTTP 200 response indicates +/// the webhook was deleted. Unlike other responses, this does not +/// inherit from because the SMTP2GO +/// API returns no data payload for delete operations. +/// +/// +public class WebhookDeleteResponse +{ + /// + /// Gets the unique request identifier assigned by the SMTP2GO API. + /// + /// + /// + /// This identifier can be used when contacting SMTP2GO support to trace + /// the deletion request. + /// + /// + [JsonPropertyName("request_id")] + public string? RequestId { get; init; } +} diff --git a/src/Smtp2Go.NET/Models/Webhooks/WebhookListResponse.cs b/src/Smtp2Go.NET/Models/Webhooks/WebhookListResponse.cs new file mode 100644 index 0000000..e4b0194 --- /dev/null +++ b/src/Smtp2Go.NET/Models/Webhooks/WebhookListResponse.cs @@ -0,0 +1,68 @@ +namespace Smtp2Go.NET.Models.Webhooks; + +using System.Text.Json.Serialization; + +/// +/// Response model for the SMTP2GO webhook listing endpoint. +/// +/// +/// +/// Contains an array of all webhook subscriptions configured for the account. +/// Each entry describes a single webhook including +/// its URL, subscribed events, and creation date. +/// +/// +public class WebhookListResponse : ApiResponse; + +/// +/// Represents a single webhook subscription in the SMTP2GO account. +/// +/// +/// +/// This model describes an existing webhook configuration, including +/// the events it is subscribed to and the URL that receives callbacks. +/// +/// +public class WebhookInfo +{ + /// + /// Gets the unique identifier of the webhook. + /// + [JsonPropertyName("id")] + public int? WebhookId { get; init; } + + /// + /// Gets the URL that receives webhook event callbacks. + /// + [JsonPropertyName("url")] + public string? WebhookUrl { get; init; } + + /// + /// Gets the event types this webhook is subscribed to. + /// + /// + /// + /// Values correspond to SMTP2GO subscription-level event names + /// (see for the strongly-typed equivalent). + /// + /// + [JsonPropertyName("events")] + public string[]? Events { get; init; } + + /// + /// Gets the sender usernames this webhook is filtered by. + /// + /// + /// + /// If null or empty, the webhook fires for all senders in the account. + /// + /// + [JsonPropertyName("usernames")] + public string[]? Usernames { get; init; } + + /// + /// Gets the output format configured for this webhook's payloads. + /// + [JsonPropertyName("output_format")] + public string? OutputFormat { get; init; } +} diff --git a/src/Smtp2Go.NET/ServiceCollectionExtensions.cs b/src/Smtp2Go.NET/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..181e50d --- /dev/null +++ b/src/Smtp2Go.NET/ServiceCollectionExtensions.cs @@ -0,0 +1,182 @@ +namespace Smtp2Go.NET; + +using Configuration; +using Http; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Options; + +/// +/// Provides extension methods for setting up SMTP2GO services in an . +/// +public static class ServiceCollectionExtensions +{ + #region Methods + + /// + /// Registers the and its dependencies using a configuration section. + /// + /// This is a convenience method that binds to the "Smtp2Go" section of the application's + /// . + /// + /// + /// The to add the services to. + /// The application configuration. + /// The so that additional calls can be chained. + /// + /// Thrown at application startup if required configuration is missing or invalid. + /// + /// + /// + /// // In Program.cs + /// builder.Services.AddSmtp2Go(builder.Configuration); + /// + /// + public static IServiceCollection AddSmtp2Go( + this IServiceCollection services, + IConfiguration configuration) + { + return services.AddSmtp2Go(options => + configuration.GetSection(Smtp2GoOptions.SectionName).Bind(options)); + } + + + /// + /// + /// Registers the and its dependencies, allowing for fine-grained + /// programmatic configuration. + /// + /// + /// Configuration is validated at application startup. If required settings are missing, + /// an is thrown with a clear error message + /// indicating what configuration is missing and how to fix it. + /// + /// + /// The to add the services to. + /// An action to configure the . + /// The so that additional calls can be chained. + /// + /// Thrown at application startup if required configuration is missing or invalid. + /// + /// + /// + /// // In Program.cs + /// builder.Services.AddSmtp2Go(options => + /// { + /// options.ApiKey = "api-XXXXXXXXXX"; + /// }); + /// + /// + public static IServiceCollection AddSmtp2Go( + this IServiceCollection services, + Action configureOptions) + { + // Configure options with the provided delegate. + services.Configure(configureOptions); + + // Register the validator for early failure with clear error messages. + services.TryAddSingleton, Smtp2GoOptionsValidator>(); + + // Add options validation at startup to fail fast with clear error messages. + services + .AddOptions() + .ValidateOnStart(); + + return services; + } + + + /// + /// Registers the with HTTP client support for SMTP2GO API calls. + /// + /// This overload configures an HTTP client with production-ready resilience including: + /// + /// Retry with exponential backoff (idempotent methods only; POST is NOT retried) + /// Circuit breaker to prevent cascading failures + /// Per-attempt and total request timeouts + /// Client-side rate limiting + /// + /// + /// + /// The to add the services to. + /// The application configuration. + /// Optional action to further configure the HTTP client. + /// The so that additional calls can be chained. + /// + /// + /// // In Program.cs + /// builder.Services.AddSmtp2GoWithHttp(builder.Configuration); + /// + /// + public static IServiceCollection AddSmtp2GoWithHttp( + this IServiceCollection services, + IConfiguration configuration, + Action? configureHttpClient = null) + { + // Register base services (options, validation). + services.AddSmtp2Go(configuration); + + // Add the typed HTTP client with resilience pipeline. + // This registers ISmtp2GoClient -> Smtp2GoClient with a configured HttpClient. + var httpClientBuilder = services.AddHttpClient(); + + // Add resilience pipeline to the HTTP client. + httpClientBuilder.AddResilienceHandler("Smtp2GoPipeline", (pipelineBuilder, context) => + { + var options = context.ServiceProvider + .GetRequiredService>() + .Get(Options.DefaultName); + + HttpClientExtensions.ConfigureResiliencePipeline(pipelineBuilder, options.Resilience); + }); + + // Allow additional HTTP client configuration. + if (configureHttpClient != null) + { + httpClientBuilder.ConfigureHttpClient(configureHttpClient); + } + + return services; + } + + + /// + /// Registers the with HTTP client support and programmatic configuration. + /// + /// The to add the services to. + /// An action to configure the . + /// Optional action to further configure the HTTP client. + /// The so that additional calls can be chained. + public static IServiceCollection AddSmtp2GoWithHttp( + this IServiceCollection services, + Action configureOptions, + Action? configureHttpClient = null) + { + // Register base services (options, validation). + services.AddSmtp2Go(configureOptions); + + // Add the typed HTTP client with resilience pipeline. + var httpClientBuilder = services.AddHttpClient(); + + // Add resilience pipeline to the HTTP client. + httpClientBuilder.AddResilienceHandler("Smtp2GoPipeline", (pipelineBuilder, context) => + { + var options = context.ServiceProvider + .GetRequiredService>() + .Get(Options.DefaultName); + + HttpClientExtensions.ConfigureResiliencePipeline(pipelineBuilder, options.Resilience); + }); + + // Allow additional HTTP client configuration. + if (configureHttpClient != null) + { + httpClientBuilder.ConfigureHttpClient(configureHttpClient); + } + + return services; + } + + #endregion +} diff --git a/src/Smtp2Go.NET/Smtp2Go.NET.csproj b/src/Smtp2Go.NET/Smtp2Go.NET.csproj new file mode 100644 index 0000000..20e8f78 --- /dev/null +++ b/src/Smtp2Go.NET/Smtp2Go.NET.csproj @@ -0,0 +1,46 @@ + + + + Smtp2Go.NET + + + Smtp2Go.NET + 1.0.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 new file mode 100644 index 0000000..c41c75f --- /dev/null +++ b/src/Smtp2Go.NET/Smtp2GoClient.cs @@ -0,0 +1,138 @@ +namespace Smtp2Go.NET; + +using System.Net; +using Configuration; +using Core; +using Internal; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Models; +using Models.Email; + +/// +/// Default implementation of . +/// +/// +/// +/// This client communicates with the SMTP2GO v3 API using HTTP POST requests. +/// Authentication is handled via the X-Smtp2go-Api-Key header, configured from +/// . +/// +/// +/// The and sub-clients are lazily +/// created and share the same and logger. +/// +/// +internal sealed partial class Smtp2GoClient : Smtp2GoResource, ISmtp2GoClient +{ + #region Constants & Statics + + /// The SMTP2GO API header name for the API key. + private const string ApiKeyHeaderName = "X-Smtp2go-Api-Key"; + + /// API endpoint for sending emails. + private const string EmailSendEndpoint = "email/send"; + + #endregion + + + #region Properties & Fields - Non-Public + + /// + /// Logger field required by [LoggerMessage] source generator. + /// Points to the same instance as the base class — see remarks. + /// + // ReSharper disable once InconsistentNaming — required by LoggerMessage source generator convention. + private readonly ILogger _logger; + + /// Lazily-created webhook sub-client. + private Smtp2GoWebhookClient? _webhookClient; + + /// Lazily-created statistics sub-client. + private Smtp2GoStatisticsClient? _statisticsClient; + + #endregion + + + #region Constructors + + /// + /// Initializes a new instance of the class. + /// + /// The HTTP client (injected by IHttpClientFactory). + /// The SMTP2GO configuration options. + /// The logger. + public Smtp2GoClient( + HttpClient httpClient, + IOptions options, + ILogger logger) + : base(httpClient, logger) + { + _logger = logger; + var opts = options.Value; + + // Set the base address from options if not already configured. + if (HttpClient.BaseAddress is null) + { + HttpClient.BaseAddress = new Uri(opts.BaseUrl); + } + + // Set the API key header. + if (!string.IsNullOrWhiteSpace(opts.ApiKey)) + { + HttpClient.DefaultRequestHeaders.Remove(ApiKeyHeaderName); + HttpClient.DefaultRequestHeaders.Add(ApiKeyHeaderName, opts.ApiKey); + } + + // Set the timeout from options. + HttpClient.Timeout = opts.Timeout; + } + + #endregion + + + #region Properties - Public + + /// + public ISmtp2GoWebhookClient Webhooks => + _webhookClient ??= new Smtp2GoWebhookClient(HttpClient, _logger); + + /// + public ISmtp2GoStatisticsClient Statistics => + _statisticsClient ??= new Smtp2GoStatisticsClient(HttpClient, _logger); + + #endregion + + + #region Methods - Public (ISmtp2GoClient) + + /// + public async Task SendEmailAsync(EmailSendRequest request, CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(request); + + LogEmailSendStarted(request.Sender, request.To?.Length ?? 0); + + var response = await PostAsync( + EmailSendEndpoint, request, ct).ConfigureAwait(false); + + LogEmailSendCompleted(response.Data?.Succeeded ?? 0, response.Data?.Failed ?? 0); + + return response; + } + + #endregion + + + #region Source-Generated Logging + + [LoggerMessage(LoggingConstants.EventIds.EmailSendStarted, LogLevel.Information, + "Sending email from {Sender} to {RecipientCount} recipient(s)")] + private partial void LogEmailSendStarted(string? sender, int recipientCount); + + [LoggerMessage(LoggingConstants.EventIds.EmailSendCompleted, LogLevel.Information, + "Email send completed: {Succeeded} succeeded, {Failed} failed")] + private partial void LogEmailSendCompleted(int succeeded, int failed); + + #endregion +} diff --git a/src/Smtp2Go.NET/Smtp2GoStatisticsClient.cs b/src/Smtp2Go.NET/Smtp2GoStatisticsClient.cs new file mode 100644 index 0000000..6aa19e1 --- /dev/null +++ b/src/Smtp2Go.NET/Smtp2GoStatisticsClient.cs @@ -0,0 +1,84 @@ +namespace Smtp2Go.NET; + +using Core; +using Internal; +using Microsoft.Extensions.Logging; +using Models.Statistics; + +/// +/// Default implementation of . +/// +/// +/// +/// This sub-client handles statistics/analytics operations by inheriting the shared +/// helper from the base class. +/// It covers the /stats/* family of SMTP2GO API endpoints. +/// +/// +internal sealed partial class Smtp2GoStatisticsClient : Smtp2GoResource, ISmtp2GoStatisticsClient +{ + #region Constants & Statics + + /// API endpoint for email statistics summary. + private const string EmailSummaryEndpoint = "stats/email_summary"; + + #endregion + + + #region Properties & Fields - Non-Public + + /// + /// Logger field required by [LoggerMessage] source generator. + /// Points to the same instance as the base class — see remarks. + /// + // ReSharper disable once InconsistentNaming — required by LoggerMessage source generator convention. + private readonly ILogger _logger; + + #endregion + + + #region Constructors + + /// + /// Initializes a new instance of the class. + /// + /// The shared HTTP client from the parent . + /// The shared logger from the parent . + internal Smtp2GoStatisticsClient(HttpClient httpClient, ILogger logger) + : base(httpClient, logger) + { + _logger = logger; + } + + #endregion + + + #region Methods - Public (ISmtp2GoStatisticsClient) + + /// + public async Task GetEmailSummaryAsync( + EmailSummaryRequest? request = null, + CancellationToken ct = default) + { + LogEmailSummaryRequested(); + + // Use an empty object if no request is specified (API requires a POST body). + var body = request ?? new EmailSummaryRequest(); + + var response = await PostAsync( + EmailSummaryEndpoint, body, ct).ConfigureAwait(false); + + return response; + } + + #endregion + + + #region Source-Generated Logging + + [LoggerMessage(LoggingConstants.EventIds.EmailSummaryRequested, LogLevel.Debug, + "Requesting email statistics summary")] + private partial void LogEmailSummaryRequested(); + + #endregion +} diff --git a/src/Smtp2Go.NET/Smtp2GoWebhookClient.cs b/src/Smtp2Go.NET/Smtp2GoWebhookClient.cs new file mode 100644 index 0000000..6c9d1c5 --- /dev/null +++ b/src/Smtp2Go.NET/Smtp2GoWebhookClient.cs @@ -0,0 +1,136 @@ +namespace Smtp2Go.NET; + +using Core; +using Internal; +using Microsoft.Extensions.Logging; +using Models.Webhooks; + +/// +/// Default implementation of . +/// +/// +/// +/// This sub-client handles webhook management operations (create, list, delete) by +/// inheriting the shared +/// helper from the base class. +/// +/// +internal sealed partial class Smtp2GoWebhookClient : Smtp2GoResource, ISmtp2GoWebhookClient +{ + #region Constants & Statics + + /// API endpoint for creating webhooks. + private const string WebhookCreateEndpoint = "webhook/add"; + + /// API endpoint for listing webhooks. + private const string WebhookListEndpoint = "webhook/view"; + + /// API endpoint for deleting webhooks. + private const string WebhookDeleteEndpoint = "webhook/remove"; + + #endregion + + + #region Properties & Fields - Non-Public + + /// + /// Logger field required by [LoggerMessage] source generator. + /// Points to the same instance as the base class — see remarks. + /// + // ReSharper disable once InconsistentNaming — required by LoggerMessage source generator convention. + private readonly ILogger _logger; + + #endregion + + + #region Constructors + + /// + /// Initializes a new instance of the class. + /// + /// The shared HTTP client from the parent . + /// The shared logger from the parent . + internal Smtp2GoWebhookClient(HttpClient httpClient, ILogger logger) + : base(httpClient, logger) + { + _logger = logger; + } + + #endregion + + + #region Methods - Public (ISmtp2GoWebhookClient) + + /// + public async Task CreateAsync( + WebhookCreateRequest request, + CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(request); + + LogWebhookCreateStarted(request.WebhookUrl); + + var response = await PostAsync( + WebhookCreateEndpoint, request, ct).ConfigureAwait(false); + + LogWebhookCreateCompleted(response.Data?.WebhookId); + + return response; + } + + + /// + public async Task ListAsync(CancellationToken ct = default) + { + LogWebhookListRequested(); + + // POST with empty body — the API requires a POST but no specific parameters for listing. + var response = await PostAsync( + WebhookListEndpoint, new { }, ct).ConfigureAwait(false); + + return response; + } + + + /// + public async Task DeleteAsync(int webhookId, CancellationToken ct = default) + { + LogWebhookDeleteStarted(webhookId); + + var request = new { id = webhookId }; + + var response = await PostAsync( + WebhookDeleteEndpoint, request, ct).ConfigureAwait(false); + + LogWebhookDeleteCompleted(webhookId); + + return response; + } + + #endregion + + + #region Source-Generated Logging + + [LoggerMessage(LoggingConstants.EventIds.WebhookCreateStarted, LogLevel.Information, + "Creating webhook for URL: {WebhookUrl}")] + private partial void LogWebhookCreateStarted(string? webhookUrl); + + [LoggerMessage(LoggingConstants.EventIds.WebhookCreateCompleted, LogLevel.Information, + "Webhook created with ID: {WebhookId}")] + private partial void LogWebhookCreateCompleted(int? webhookId); + + [LoggerMessage(LoggingConstants.EventIds.WebhookListRequested, LogLevel.Debug, + "Listing configured webhooks")] + private partial void LogWebhookListRequested(); + + [LoggerMessage(LoggingConstants.EventIds.WebhookDeleteStarted, LogLevel.Information, + "Deleting webhook: {WebhookId}")] + private partial void LogWebhookDeleteStarted(int webhookId); + + [LoggerMessage(LoggingConstants.EventIds.WebhookDeleteCompleted, LogLevel.Information, + "Webhook deleted: {WebhookId}")] + private partial void LogWebhookDeleteCompleted(int webhookId); + + #endregion +} diff --git a/temp b/temp deleted file mode 100644 index 8b13789..0000000 --- a/temp +++ /dev/null @@ -1 +0,0 @@ - diff --git a/tests/Directory.Build.props b/tests/Directory.Build.props new file mode 100644 index 0000000..85186d9 --- /dev/null +++ b/tests/Directory.Build.props @@ -0,0 +1,45 @@ + + + + + + + + + + + net10.0 + + + Exe + + + false + + false + + + false + false + false + + + false + + + $(MSBuildThisFileDirectory)..\Smtp2Go.NET.snk + + + diff --git a/tests/Smtp2Go.NET.IntegrationTests/Email/EmailSendLiveIntegrationTests.cs b/tests/Smtp2Go.NET.IntegrationTests/Email/EmailSendLiveIntegrationTests.cs new file mode 100644 index 0000000..1e3fb2c --- /dev/null +++ b/tests/Smtp2Go.NET.IntegrationTests/Email/EmailSendLiveIntegrationTests.cs @@ -0,0 +1,78 @@ +namespace Smtp2Go.NET.IntegrationTests.Email; + +using Fixtures; +using Helpers; +using Smtp2Go.NET.Models.Email; + +/// +/// Live integration tests for the endpoint +/// using the live API key (emails are actually delivered). +/// +/// +/// +/// These tests send real emails to the configured test recipient. Use with caution +/// and ensure the test recipient is a controlled mailbox to avoid spamming. +/// +/// +[Trait("Category", "Integration.Live")] +public sealed class EmailSendLiveIntegrationTests : 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 EmailSendLiveIntegrationTests(Smtp2GoLiveFixture fixture) + { + _fixture = fixture; + } + + #endregion + + + #region Send Email - Live Delivery + + [Fact] + public async Task SendEmail_WithLiveKey_DeliversToRecipient() + { + // Fail if live secrets are not configured. + TestSecretValidator.AssertLiveSecretsPresent(); + + // Arrange + var request = new EmailSendRequest + { + Sender = _fixture.TestSender, + To = [_fixture.TestRecipient], + Subject = $"Smtp2Go.NET Live Integration Test - {DateTime.UtcNow:O}", + HtmlBody = $""" +

Smtp2Go.NET Live Integration Test

+

This email was sent by the Smtp2Go.NET integration test suite.

+

No action is required. This email confirms live delivery is working correctly.

+
+

Sent at {DateTime.UtcNow:O}

+ """, + TextBody = "This is a live integration test email from Smtp2Go.NET. No action required." + }; + + // Act + var response = await _fixture.Client.SendEmailAsync(request, 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/EmailSendSandboxIntegrationTests.cs b/tests/Smtp2Go.NET.IntegrationTests/Email/EmailSendSandboxIntegrationTests.cs new file mode 100644 index 0000000..d3cb0c8 --- /dev/null +++ b/tests/Smtp2Go.NET.IntegrationTests/Email/EmailSendSandboxIntegrationTests.cs @@ -0,0 +1,336 @@ +namespace Smtp2Go.NET.IntegrationTests.Email; + +using Fixtures; +using Helpers; +using Smtp2Go.NET.Exceptions; +using Smtp2Go.NET.Models.Email; + +/// +/// Integration tests for the endpoint +/// using the sandbox API key (emails accepted but not delivered). +/// +[Trait("Category", "Integration")] +public sealed class EmailSendSandboxIntegrationTests : 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 EmailSendSandboxIntegrationTests(Smtp2GoSandboxFixture fixture) + { + _fixture = fixture; + } + + #endregion + + + #region Send Email - Success + + [Fact] + public async Task SendEmail_WithSandboxKey_ReturnsSuccessResponse() + { + // Fail if sandbox secrets are not configured. + TestSecretValidator.AssertSandboxSecretsPresent(); + + // Arrange + var request = new EmailSendRequest + { + Sender = _fixture.TestSender, + To = ["sandbox-recipient@example.com"], + Subject = $"Smtp2Go.NET Integration Test - {DateTime.UtcNow:O}", + TextBody = "This is an automated integration test. No action needed." + }; + + // Act + var response = await _fixture.Client.SendEmailAsync(request, TestContext.Current.CancellationToken); + + // Assert — The sandbox API should accept the email 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, "at least one recipient should succeed"); + response.Data.EmailId.Should().NotBeNullOrWhiteSpace("the API should return an email ID"); + } + + + [Fact] + public async Task SendEmail_WithHtmlBody_ReturnsSuccessResponse() + { + // Fail if sandbox secrets are not configured. + TestSecretValidator.AssertSandboxSecretsPresent(); + + // Arrange + var request = new EmailSendRequest + { + Sender = _fixture.TestSender, + To = ["sandbox-recipient@example.com"], + Subject = $"HTML Test - {DateTime.UtcNow:O}", + HtmlBody = "

Integration Test

This is an automated test with HTML body.

" + }; + + // Act + var response = await _fixture.Client.SendEmailAsync(request, TestContext.Current.CancellationToken); + + // Assert + response.Should().NotBeNull(); + response.Data.Should().NotBeNull(); + response.Data!.Succeeded.Should().BeGreaterThanOrEqualTo(1); + } + + + [Fact] + public async Task SendEmail_WithMultipleRecipients_ReturnsSuccessResponse() + { + // Fail if sandbox secrets are not configured. + TestSecretValidator.AssertSandboxSecretsPresent(); + + // Arrange + var request = new EmailSendRequest + { + Sender = _fixture.TestSender, + To = ["recipient1@example.com", "recipient2@example.com"], + Subject = $"Multi-Recipient Test - {DateTime.UtcNow:O}", + TextBody = "This email was sent to multiple recipients." + }; + + // Act + var response = await _fixture.Client.SendEmailAsync(request, TestContext.Current.CancellationToken); + + // Assert + response.Should().NotBeNull(); + response.Data.Should().NotBeNull(); + // SMTP2GO sandbox may count multiple recipients differently — assert at least 1 succeeded. + response.Data!.Succeeded.Should().BeGreaterThanOrEqualTo(1, "at least one recipient should succeed"); + } + + + [Fact] + public async Task SendEmail_WithCcAndBcc_ReturnsSuccessResponse() + { + // Fail if sandbox secrets are not configured. + TestSecretValidator.AssertSandboxSecretsPresent(); + + // Arrange + var request = new EmailSendRequest + { + Sender = _fixture.TestSender, + To = ["to@example.com"], + Cc = ["cc@example.com"], + Bcc = ["bcc@example.com"], + Subject = $"CC/BCC Test - {DateTime.UtcNow:O}", + TextBody = "This email includes CC and BCC recipients." + }; + + // Act + var response = await _fixture.Client.SendEmailAsync(request, TestContext.Current.CancellationToken); + + // Assert + response.Should().NotBeNull(); + response.Data.Should().NotBeNull(); + response.Data!.Succeeded.Should().BeGreaterThanOrEqualTo(1); + } + + + [Fact] + public async Task SendEmail_WithCustomHeaders_ReturnsSuccessResponse() + { + // Fail if sandbox secrets are not configured. + TestSecretValidator.AssertSandboxSecretsPresent(); + + // Arrange + var request = new EmailSendRequest + { + Sender = _fixture.TestSender, + To = ["sandbox-recipient@example.com"], + Subject = $"Custom Headers Test - {DateTime.UtcNow:O}", + TextBody = "This email includes custom headers.", + CustomHeaders = + [ + new CustomHeader { Header = "X-Test-Id", Value = Guid.NewGuid().ToString() }, + new CustomHeader { Header = "X-Source", Value = "Smtp2Go.NET.IntegrationTests" } + ] + }; + + // Act + var response = await _fixture.Client.SendEmailAsync(request, TestContext.Current.CancellationToken); + + // Assert + response.Should().NotBeNull(); + response.Data.Should().NotBeNull(); + response.Data!.Succeeded.Should().BeGreaterThanOrEqualTo(1); + } + + #endregion + + + #region Send Email - Attachments + + [Fact] + public async Task SendEmail_WithAttachment_ReturnsSuccessResponse() + { + // Fail if sandbox secrets are not configured. + TestSecretValidator.AssertSandboxSecretsPresent(); + + // Arrange — Create a small text file attachment. + var fileContent = "This is a test attachment file content."u8.ToArray(); + + var request = new EmailSendRequest + { + Sender = _fixture.TestSender, + To = ["sandbox-recipient@example.com"], + Subject = $"Attachment Test - {DateTime.UtcNow:O}", + TextBody = "This email includes a file attachment.", + Attachments = + [ + new Attachment + { + Filename = "test-report.txt", + Fileblob = Convert.ToBase64String(fileContent), + Mimetype = "text/plain" + } + ] + }; + + // Act + var response = await _fixture.Client.SendEmailAsync(request, TestContext.Current.CancellationToken); + + // Assert + response.Should().NotBeNull(); + response.Data.Should().NotBeNull(); + response.Data!.Succeeded.Should().BeGreaterThanOrEqualTo(1); + } + + + [Fact] + public async Task SendEmail_WithMultipleAttachments_ReturnsSuccessResponse() + { + // Fail if sandbox secrets are not configured. + TestSecretValidator.AssertSandboxSecretsPresent(); + + // Arrange — Create multiple attachments of different MIME types. + var textContent = "Plain text attachment."u8.ToArray(); + var csvContent = "Name,Value\nTest,123\n"u8.ToArray(); + + var request = new EmailSendRequest + { + Sender = _fixture.TestSender, + To = ["sandbox-recipient@example.com"], + Subject = $"Multiple Attachments Test - {DateTime.UtcNow:O}", + TextBody = "This email includes multiple file attachments.", + Attachments = + [ + new Attachment + { + Filename = "notes.txt", + Fileblob = Convert.ToBase64String(textContent), + Mimetype = "text/plain" + }, + new Attachment + { + Filename = "data.csv", + Fileblob = Convert.ToBase64String(csvContent), + Mimetype = "text/csv" + } + ] + }; + + // Act + var response = await _fixture.Client.SendEmailAsync(request, TestContext.Current.CancellationToken); + + // Assert + response.Should().NotBeNull(); + response.Data.Should().NotBeNull(); + response.Data!.Succeeded.Should().BeGreaterThanOrEqualTo(1); + } + + + [Fact] + public async Task SendEmail_WithInlineAttachment_ReturnsSuccessResponse() + { + // Fail if sandbox secrets are not configured. + TestSecretValidator.AssertSandboxSecretsPresent(); + + // Arrange — Create a minimal 1x1 red PNG for inline embedding. + // This is the smallest valid PNG: 8-byte signature + IHDR + IDAT + IEND. + byte[] pixelPng = + [ + 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, // PNG signature + 0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52, // IHDR chunk length + type + 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, // 1x1 pixels + 0x08, 0x02, 0x00, 0x00, 0x00, 0x90, 0x77, 0x53, // 8-bit RGB + CRC + 0xDE, 0x00, 0x00, 0x00, 0x0C, 0x49, 0x44, 0x41, // IDAT chunk + 0x54, 0x08, 0xD7, 0x63, 0xF8, 0xCF, 0xC0, 0x00, // compressed pixel data + 0x00, 0x00, 0x02, 0x00, 0x01, 0xE2, 0x21, 0xBC, // Adler32 + CRC + 0x33, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4E, // IEND chunk + 0x44, 0xAE, 0x42, 0x60, 0x82 // IEND CRC + ]; + + var request = new EmailSendRequest + { + Sender = _fixture.TestSender, + To = ["sandbox-recipient@example.com"], + Subject = $"Inline Attachment Test - {DateTime.UtcNow:O}", + HtmlBody = """ +

Inline Image Test

+

The image below is embedded via cid: reference:

+ Test Logo + """, + Inlines = + [ + new Attachment + { + Filename = "test-logo.png", + Fileblob = Convert.ToBase64String(pixelPng), + Mimetype = "image/png" + } + ] + }; + + // Act + var response = await _fixture.Client.SendEmailAsync(request, TestContext.Current.CancellationToken); + + // Assert + response.Should().NotBeNull(); + response.Data.Should().NotBeNull(); + response.Data!.Succeeded.Should().BeGreaterThanOrEqualTo(1); + } + + #endregion + + + #region Send Email - Error Handling + + [Fact] + public async Task SendEmail_WithInvalidApiKey_ThrowsSmtp2GoApiException() + { + // Arrange — Create a client with a deliberately invalid API key. + // SMTP2GO requires API keys in format 'api-[A-Za-z0-9]{32}' (36 chars total). + // Use a correctly-formatted but nonexistent key to trigger an auth error (not a format error). + var invalidClient = Smtp2GoClientFactory.CreateClient("api-00000000000000000000000000000000"); + + var request = new EmailSendRequest + { + Sender = _fixture.TestSender, + To = ["recipient@example.com"], + Subject = "Invalid Key Test", + TextBody = "This should fail." + }; + + // Act + var act = async () => await invalidClient.SendEmailAsync(request, TestContext.Current.CancellationToken); + + // Assert + await act.Should().ThrowAsync(); + } + + #endregion +} diff --git a/tests/Smtp2Go.NET.IntegrationTests/Fixtures/CloudflareTunnelManager.cs b/tests/Smtp2Go.NET.IntegrationTests/Fixtures/CloudflareTunnelManager.cs new file mode 100644 index 0000000..8de1042 --- /dev/null +++ b/tests/Smtp2Go.NET.IntegrationTests/Fixtures/CloudflareTunnelManager.cs @@ -0,0 +1,542 @@ +namespace Smtp2Go.NET.IntegrationTests.Fixtures; + +using System.Diagnostics; +using System.Net; +using System.Net.Http.Json; +using System.Net.Sockets; +using System.Text.Json.Serialization; +using System.Text.RegularExpressions; + +/// +/// Manages a Cloudflare Quick Tunnel for exposing a local webhook receiver to the internet. +/// +/// +/// +/// This manager starts a cloudflared tunnel --url process pointing at a local port. +/// Cloudflare Quick Tunnels require no authentication — they generate a random +/// https://{random}.trycloudflare.com URL that proxies traffic to the local port. +/// +/// +/// The public URL is discovered by parsing cloudflared's stderr output, where it logs +/// "https://xxx.trycloudflare.com" once the tunnel is established. +/// +/// +/// DNS Caching Issue: Quick Tunnel subdomains on trycloudflare.com +/// do NOT use wildcard DNS — each tunnel gets its own DNS record created on-the-fly. +/// If the local resolver queries the hostname before the record exists, it caches +/// the NXDOMAIN response for up to 1800 seconds (the SOA minimum TTL). To work around +/// this, uses Cloudflare's DNS-over-HTTPS (DoH) +/// API at cloudflare-dns.com/dns-query to resolve DNS directly, bypassing the +/// Windows DNS cache entirely. +/// +/// +/// Prerequisites: cloudflared must be installed. If not on PATH, +/// the manager checks common install locations. Use +/// to locate the executable. +/// +/// +/// Advantages: +/// +/// No authentication token required (Quick Tunnels are free and zero-config) +/// No interstitial page or request blocking for POST requests +/// No port conflict issues (no local API server) +/// +/// +/// +internal sealed partial class CloudflareTunnelManager : IAsyncDisposable +{ + #region Constants & Statics + + /// Maximum time to wait for cloudflared to start and expose a tunnel. + private static readonly TimeSpan StartupTimeout = TimeSpan.FromSeconds(30); + + /// + /// Common install locations for cloudflared on Windows. + /// Checked when cloudflared is not on the system PATH. + /// + private static readonly string[] CommonWindowsPaths = + [ + @"C:\Program Files (x86)\cloudflared\cloudflared.exe", + @"C:\Program Files\cloudflared\cloudflared.exe", + Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "Programs", "cloudflared", "cloudflared.exe") + ]; + + /// + /// HTTP client for Cloudflare DNS-over-HTTPS (DoH) queries. + /// Used to resolve tunnel hostnames without touching the Windows DNS cache. + /// + private static readonly HttpClient DohClient = new() { Timeout = TimeSpan.FromSeconds(5) }; + + #endregion + + + #region Properties & Fields - Non-Public + + /// The cloudflared process. + private Process? _cloudflaredProcess; + + #endregion + + + #region Properties & Fields - Public + + /// Gets the public HTTPS URL of the active tunnel, or null if not started. + public string? PublicUrl { get; private set; } + + #endregion + + + #region Methods + + /// + /// Starts a Cloudflare Quick Tunnel to the specified local port. + /// + /// + /// + /// Quick Tunnels require no authentication — they create a temporary, randomly-named + /// tunnel that proxies HTTPS traffic to the specified local port. + /// + /// + /// DNS Propagation: Quick Tunnel URLs may not be immediately resolvable + /// after creation. Use after this method to + /// verify the tunnel is reachable before registering webhooks or expecting callbacks. + /// + /// + /// The local port to tunnel to. + /// The public HTTPS URL for the tunnel (e.g., https://xxx.trycloudflare.com). + /// + /// Thrown if cloudflared is not found, fails to start, or no tunnel URL is discovered. + /// + public async Task StartTunnelAsync(int localPort) + { + if (_cloudflaredProcess != null) + throw new InvalidOperationException("A tunnel is already running. Dispose first."); + + // Locate the cloudflared executable. + var cloudflaredPath = FindCloudflaredPath() + ?? throw new InvalidOperationException( + "cloudflared is not installed. Install from https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/"); + + // Start cloudflared process with Quick Tunnel. + // The --url flag tells cloudflared to create a Quick Tunnel (no account/auth required). + var startInfo = new ProcessStartInfo + { + FileName = cloudflaredPath, + Arguments = $"tunnel --url http://localhost:{localPort}", + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + }; + + _cloudflaredProcess = Process.Start(startInfo) + ?? throw new InvalidOperationException("Failed to start cloudflared process."); + + // Consume stdout in the background — cloudflared may log connection info there. + _ = Task.Run(async () => + { + try + { + while (await _cloudflaredProcess.StandardOutput.ReadLineAsync() is { } line) + Console.Error.WriteLine($"[cloudflared:stdout] {line}"); + } + catch { /* Process exited or stream closed. */ } + }); + + // Parse stderr to discover the public tunnel URL. + // cloudflared logs the tunnel URL to stderr once established. + var publicUrl = await DiscoverTunnelUrlFromStderrAsync(_cloudflaredProcess); + + if (publicUrl != null) + { + PublicUrl = publicUrl; + + return publicUrl; + } + + // Timeout — kill the process and throw. + await DisposeAsync(); + + throw new InvalidOperationException( + $"cloudflared did not expose a tunnel within {StartupTimeout.TotalSeconds}s. " + + "Ensure cloudflared is installed correctly."); + } + + + /// + /// Polls a tunnel URL until it responds with 200 OK, indicating the tunnel is reachable. + /// + /// + /// + /// Cloudflare Quick Tunnels may not be immediately reachable after the URL is reported + /// because the DNS record for the random subdomain needs time to propagate. + /// + /// + /// DNS Cache Bypass: The trycloudflare.com SOA minimum TTL is + /// 1800 seconds, meaning NXDOMAIN responses are cached for up to 30 minutes by the + /// Windows DNS client. To avoid this, this method resolves DNS via Cloudflare's + /// DNS-over-HTTPS (DoH) API and connects directly to the resolved IP using a custom + /// . + /// + /// + /// The full URL to poll through the tunnel. + /// true if the tunnel became reachable; false if the 60-second timeout expired. + public async Task WaitForTunnelReachableAsync(string healthUrl) + { + // Allow up to 60 seconds for DNS propagation + tunnel readiness. + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(60)); + + // Create an HttpClient that bypasses the Windows DNS cache by resolving + // hostnames via Cloudflare's DNS-over-HTTPS API. + using var httpClient = CreateDnsBypassingHttpClient(); + var attempt = 0; + + while (!cts.Token.IsCancellationRequested) + { + try + { + var response = await httpClient.GetAsync(healthUrl, cts.Token); + + if (response.IsSuccessStatusCode) + { + Console.Error.WriteLine($"[CloudflareTunnelManager] Tunnel reachable after {attempt + 1} attempt(s)."); + + return true; + } + + Console.Error.WriteLine($"[CloudflareTunnelManager] Health check attempt {attempt + 1}: HTTP {(int)response.StatusCode}"); + } + catch (TaskCanceledException) when (!cts.Token.IsCancellationRequested) + { + // Individual request timed out — retry. + Console.Error.WriteLine($"[CloudflareTunnelManager] Health check attempt {attempt + 1}: request timed out"); + } + catch (HttpRequestException ex) when (!cts.Token.IsCancellationRequested) + { + Console.Error.WriteLine($"[CloudflareTunnelManager] Health check attempt {attempt + 1}: {ex.Message}"); + } + catch (Exception) when (!cts.Token.IsCancellationRequested) + { + // Tunnel not ready yet — retry. + } + + attempt++; + + // Fixed 3-second interval between attempts. + await Task.Delay(3000, cts.Token).ConfigureAwait(ConfigureAwaitOptions.SuppressThrowing); + } + + Console.Error.WriteLine($"[CloudflareTunnelManager] Tunnel not reachable after {attempt} attempts over 60 seconds."); + + return false; + } + + + /// + /// Finds the cloudflared executable path by checking PATH and common install locations. + /// + /// The full path to the cloudflared executable, or null if not found. + public static string? FindCloudflaredPath() + { + // First, check if cloudflared is on the system PATH. + try + { + using var process = Process.Start(new ProcessStartInfo + { + FileName = "cloudflared", + Arguments = "version", + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + }); + + process?.WaitForExit(5000); + + if (process is { ExitCode: 0 }) + return "cloudflared"; + } + catch + { + // Not on PATH — check common install locations. + } + + // Check common Windows install locations. + foreach (var path in CommonWindowsPaths) + { + if (File.Exists(path)) + return path; + } + + return null; + } + + + /// + /// Checks whether cloudflared is installed and available. + /// + /// true if cloudflared is found; otherwise, false. + public static bool IsCloudflaredInstalled() + { + return FindCloudflaredPath() != null; + } + + #endregion + + + #region Methods - Private + + /// + /// Reads cloudflared's stderr output to discover the public tunnel URL. + /// + /// + /// + /// cloudflared logs tunnel information to stderr. The public URL appears in a line like: + /// ... | https://random-words.trycloudflare.com + /// + /// + /// After discovering the URL, stderr consumption continues in a background task + /// to capture connection registration and diagnostic messages. + /// + /// + private async Task DiscoverTunnelUrlFromStderrAsync(Process process) + { + using var cts = new CancellationTokenSource(StartupTimeout); + + try + { + // Read stderr line by line — cloudflared logs tunnel info there. + while (!cts.Token.IsCancellationRequested) + { + var line = await process.StandardError.ReadLineAsync(cts.Token); + + // Process exited or stream ended. + if (line == null) + break; + + // Log to console for debugging (visible in test output). + Console.Error.WriteLine($"[cloudflared] {line}"); + + // Look for the trycloudflare.com URL. + var match = TryCloudflareUrlRegex().Match(line); + + if (match.Success) + { + // Continue reading stderr in the background for diagnostics. + _ = Task.Run(async () => + { + try + { + while (await process.StandardError.ReadLineAsync() is { } stderrLine) + Console.Error.WriteLine($"[cloudflared] {stderrLine}"); + } + catch { /* Process exited or stream closed. */ } + }); + + return match.Value; + } + + // Also check for fatal errors to fail fast. + if (line.Contains("ERR", StringComparison.OrdinalIgnoreCase) && + line.Contains("failed", StringComparison.OrdinalIgnoreCase)) + { + Console.Error.WriteLine($"[CloudflareTunnelManager] Possible error detected: {line}"); + } + } + } + catch (OperationCanceledException) + { + // Timeout — fall through to return null. + } + + return null; + } + + + /// + /// Creates an that resolves DNS via Cloudflare's DNS-over-HTTPS + /// (DoH) API, completely bypassing the Windows DNS cache. + /// + /// + /// + /// The trycloudflare.com zone has an SOA minimum TTL of 1800 seconds, causing + /// Windows to cache NXDOMAIN responses for up to 30 minutes. This makes it impossible + /// to reach a newly-created Quick Tunnel using the system DNS resolver. + /// + /// + /// This client uses a that: + /// + /// Resolves the hostname via https://cloudflare-dns.com/dns-query (JSON API) + /// Connects a TCP socket directly to the resolved IP address + /// + /// Since Cloudflare is the authoritative DNS provider for trycloudflare.com, + /// their resolver will have the record available as soon as it's created. + /// + /// + internal static HttpClient CreateDnsBypassingHttpClient() + { + var handler = new SocketsHttpHandler + { + // Disable connection pooling — each request gets a fresh DNS lookup. + PooledConnectionLifetime = TimeSpan.Zero, + + ConnectCallback = async (context, ct) => + { + // Resolve DNS via Cloudflare's DoH API instead of the system resolver. + var ip = await ResolveDnsViaCloudflareAsync(context.DnsEndPoint.Host, ct); + + if (ip == null) + { + throw new HttpRequestException( + $"DNS resolution via Cloudflare DoH failed for {context.DnsEndPoint.Host}"); + } + + Console.Error.WriteLine( + $"[CloudflareTunnelManager] DoH resolved {context.DnsEndPoint.Host} → {ip}"); + + var socket = new Socket(SocketType.Stream, ProtocolType.Tcp); + socket.NoDelay = true; + + try + { + await socket.ConnectAsync(new IPEndPoint(ip, context.DnsEndPoint.Port), ct); + + return new NetworkStream(socket, ownsSocket: true); + } + catch + { + socket.Dispose(); + + throw; + } + } + }; + + return new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(10) }; + } + + + /// + /// Resolves a hostname to an IPv4 address using Cloudflare's DNS-over-HTTPS (DoH) JSON API. + /// + /// + /// + /// Queries https://cloudflare-dns.com/dns-query?name={hostname}&type=A + /// with the Accept: application/dns-json header. Returns the first A record + /// (type 1) from the response, or null if no record is found (NXDOMAIN). + /// + /// + /// The hostname to resolve. + /// Cancellation token. + /// The resolved IPv4 address, or null if the record doesn't exist yet. + private static async Task ResolveDnsViaCloudflareAsync(string hostname, CancellationToken ct) + { + try + { + using var request = new HttpRequestMessage( + HttpMethod.Get, + $"https://cloudflare-dns.com/dns-query?name={Uri.EscapeDataString(hostname)}&type=A"); + + request.Headers.Add("Accept", "application/dns-json"); + + var response = await DohClient.SendAsync(request, ct); + response.EnsureSuccessStatusCode(); + + var dohResponse = await response.Content.ReadFromJsonAsync(ct); + + // Find the first A record (type 1) in the answers. + var aRecord = dohResponse?.Answer?.FirstOrDefault(a => a.Type == 1); + + return aRecord?.Data is { } ip ? IPAddress.Parse(ip) : null; + } + catch (Exception ex) + { + Console.Error.WriteLine($"[CloudflareTunnelManager] DoH query for {hostname} failed: {ex.Message}"); + + return null; + } + } + + + /// + /// Compiled regex to extract the trycloudflare.com URL from cloudflared log output. + /// + /// + /// Matches URLs like https://random-words-here.trycloudflare.com. + /// + [GeneratedRegex(@"https://[\w-]+\.trycloudflare\.com", RegexOptions.Compiled)] + private static partial Regex TryCloudflareUrlRegex(); + + #endregion + + + #region IAsyncDisposable + + /// + public async ValueTask DisposeAsync() + { + if (_cloudflaredProcess is { HasExited: false }) + { + try + { + _cloudflaredProcess.Kill(entireProcessTree: true); + await _cloudflaredProcess.WaitForExitAsync(); + } + catch + { + // Best-effort cleanup; process may have already exited. + } + } + + _cloudflaredProcess?.Dispose(); + _cloudflaredProcess = null; + PublicUrl = null; + } + + #endregion + + + #region Inner Types + + /// + /// Minimal DTO for Cloudflare DNS-over-HTTPS JSON responses. + /// See: https://developers.cloudflare.com/1.1.1.1/encryption/dns-over-https/make-api-requests/dns-json/ + /// + private sealed class DohResponse + { + /// DNS response status code (0 = NOERROR, 3 = NXDOMAIN). + [JsonPropertyName("Status")] + public int Status { get; init; } + + /// DNS answer records. + [JsonPropertyName("Answer")] + public DohAnswer[]? Answer { get; init; } + } + + + /// + /// Represents a single DNS answer record in a DoH JSON response. + /// + private sealed class DohAnswer + { + /// The record owner name. + [JsonPropertyName("name")] + public string? Name { get; init; } + + /// The DNS record type (1 = A, 28 = AAAA, 5 = CNAME). + [JsonPropertyName("type")] + public int Type { get; init; } + + /// The record TTL in seconds. + [JsonPropertyName("TTL")] + public int Ttl { get; init; } + + /// The record value (e.g., an IP address for A records). + [JsonPropertyName("data")] + public string? Data { get; init; } + } + + #endregion +} diff --git a/tests/Smtp2Go.NET.IntegrationTests/Fixtures/Smtp2GoLiveFixture.cs b/tests/Smtp2Go.NET.IntegrationTests/Fixtures/Smtp2GoLiveFixture.cs new file mode 100644 index 0000000..f15faa2 --- /dev/null +++ b/tests/Smtp2Go.NET.IntegrationTests/Fixtures/Smtp2GoLiveFixture.cs @@ -0,0 +1,67 @@ +namespace Smtp2Go.NET.IntegrationTests.Fixtures; + +using Helpers; +using Microsoft.Extensions.Hosting; + +/// +/// An xUnit class fixture that sets up a dependency injection container with the Smtp2Go.NET SDK +/// registered using the live API key. +/// +/// +/// +/// Live tests perform actual email delivery and webhook operations against the real SMTP2GO API. +/// The live API key is configured via user secrets at Smtp2Go:ApiKey:Live. +/// +/// +/// Warning: Live tests will send real emails and create/delete real webhooks. +/// Use with caution and ensure the test recipient is a controlled mailbox. +/// +/// +public sealed class Smtp2GoLiveFixture : IAsyncDisposable +{ + #region Properties & Fields - Non-Public + + /// The application host managing the DI container lifetime. + private readonly IHost _host; + + #endregion + + + #region Constructors + + /// + /// Initializes a new instance of the class. + /// + public Smtp2GoLiveFixture() + { + (_host, Client) = Smtp2GoClientFactory.CreateHostedClient(TestConfiguration.Settings.ApiKey.Live); + } + + #endregion + + + #region Properties & Fields - Public + + /// Gets the fully configured using the live API key. + public ISmtp2GoClient Client { get; } + + /// Gets the verified sender email address configured for tests. + public string TestSender => TestConfiguration.Settings.TestSender; + + /// Gets the test recipient email address for live delivery tests. + public string TestRecipient => TestConfiguration.Settings.TestRecipient; + + #endregion + + + #region Methods Impl + + /// + public async ValueTask DisposeAsync() + { + _host.Dispose(); + await Task.CompletedTask; + } + + #endregion +} diff --git a/tests/Smtp2Go.NET.IntegrationTests/Fixtures/Smtp2GoSandboxFixture.cs b/tests/Smtp2Go.NET.IntegrationTests/Fixtures/Smtp2GoSandboxFixture.cs new file mode 100644 index 0000000..60929a9 --- /dev/null +++ b/tests/Smtp2Go.NET.IntegrationTests/Fixtures/Smtp2GoSandboxFixture.cs @@ -0,0 +1,65 @@ +namespace Smtp2Go.NET.IntegrationTests.Fixtures; + +using Helpers; +using Microsoft.Extensions.Hosting; + +/// +/// An xUnit class fixture that sets up a dependency injection container with the Smtp2Go.NET SDK +/// registered using the sandbox API key. +/// +/// +/// +/// Sandbox tests verify API contract behavior without actual email delivery. +/// The sandbox API key is configured via user secrets at Smtp2Go:ApiKey:Sandbox. +/// +/// +/// This fixture uses the library's +/// extension method (via ) to register the client via DI, +/// ensuring the actual DI configuration is tested. +/// +/// +public sealed class Smtp2GoSandboxFixture : IAsyncDisposable +{ + #region Properties & Fields - Non-Public + + /// The application host managing the DI container lifetime. + private readonly IHost _host; + + #endregion + + + #region Constructors + + /// + /// Initializes a new instance of the class. + /// + public Smtp2GoSandboxFixture() + { + (_host, Client) = Smtp2GoClientFactory.CreateHostedClient(TestConfiguration.Settings.ApiKey.Sandbox); + } + + #endregion + + + #region Properties & Fields - Public + + /// Gets the fully configured using the sandbox API key. + public ISmtp2GoClient Client { get; } + + /// Gets the verified sender email address configured for tests. + public string TestSender => TestConfiguration.Settings.TestSender; + + #endregion + + + #region Methods Impl + + /// + public async ValueTask DisposeAsync() + { + _host.Dispose(); + await Task.CompletedTask; + } + + #endregion +} diff --git a/tests/Smtp2Go.NET.IntegrationTests/Fixtures/TestConfiguration.cs b/tests/Smtp2Go.NET.IntegrationTests/Fixtures/TestConfiguration.cs new file mode 100644 index 0000000..85d95d0 --- /dev/null +++ b/tests/Smtp2Go.NET.IntegrationTests/Fixtures/TestConfiguration.cs @@ -0,0 +1,115 @@ +namespace Smtp2Go.NET.IntegrationTests.Fixtures; + +using System.Reflection; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Configuration.UserSecrets; + +/// +/// A helper class to build configuration from multiple sources (JSON, Environment, User Secrets) +/// for use in integration tests. +/// +/// +/// +/// Configuration sources are loaded in priority order (lowest to highest): +/// +/// appsettings.json — template/placeholder values (checked into source control) +/// Environment variables — CI/CD pipelines or container configuration +/// User Secrets — local developer secrets (not checked into source control) +/// +/// +/// +public static class TestConfiguration +{ + #region Constants & Statics + + /// Gets the lazily-initialized configuration root. + public static IConfigurationRoot Configuration { get; } + + /// Gets the SMTP2GO test settings loaded from the configuration. + public static TestSmtp2GoSettings Settings { get; } + + #endregion + + + #region Constructors + + /// Initializes the static TestConfiguration by building the configuration sources. + static TestConfiguration() + { + // Build configuration from appsettings.json, environment variables, and user secrets. + var builder = new ConfigurationBuilder() + .AddJsonFile("appsettings.json", optional: true, reloadOnChange: false) + .AddEnvironmentVariables(); + + // Dynamically find the assembly containing the user secrets. This is necessary because + // the UserSecretsId is defined in the test project, not the source library. We scan + // the loaded assemblies to find one that has the attribute and use it as the anchor. + var testAssemblyWithSecrets = AppDomain.CurrentDomain.GetAssemblies() + .FirstOrDefault(a => a.GetCustomAttribute() != null); + + if (testAssemblyWithSecrets != null) + builder.AddUserSecrets(testAssemblyWithSecrets); + + Configuration = builder.Build(); + + // Bind the configuration to a strongly-typed settings object. + Settings = new TestSmtp2GoSettings(); + Configuration.GetSection("Smtp2Go").Bind(Settings); + } + + #endregion +} + + +/// +/// Represents the configuration options required for SMTP2GO integration tests. +/// +/// +/// +/// Contains real secrets (API keys, sender/recipient addresses) that must be configured +/// via user secrets or environment variables. Webhook Basic Auth credentials are NOT +/// included here — they are arbitrary test constants defined by the tests themselves. +/// +/// +public class TestSmtp2GoSettings +{ + #region Properties & Fields - Public + + /// Gets the API key settings (sandbox and live). + public ApiKeySettings ApiKey { get; set; } = new(); + + /// + /// Gets or sets the verified sender email address for all integration tests. + /// Must be a sender verified on the SMTP2GO account (e.g., noreply@yourdomain.com). + /// + public string TestSender { get; set; } = string.Empty; + + /// Gets or sets the real email address for live delivery tests. + public string TestRecipient { get; set; } = string.Empty; + + /// Gets or sets the SMTP2GO API base URL. + public string BaseUrl { get; set; } = "https://api.smtp2go.com/v3/"; + + #endregion + + + #region Nested Types + + /// API key configuration with separate sandbox and live keys. + public class ApiKeySettings + { + /// + /// Gets or sets the sandbox API key. Emails are accepted but not delivered. + /// Used for API contract testing without incurring delivery costs. + /// + public string Sandbox { get; set; } = string.Empty; + + /// + /// Gets or sets the live API key. Emails are actually delivered. + /// Used for end-to-end delivery and webhook tests. + /// + public string Live { get; set; } = string.Empty; + } + + #endregion +} diff --git a/tests/Smtp2Go.NET.IntegrationTests/Fixtures/WebhookReceiverFixture.cs b/tests/Smtp2Go.NET.IntegrationTests/Fixtures/WebhookReceiverFixture.cs new file mode 100644 index 0000000..654e1c0 --- /dev/null +++ b/tests/Smtp2Go.NET.IntegrationTests/Fixtures/WebhookReceiverFixture.cs @@ -0,0 +1,300 @@ +namespace Smtp2Go.NET.IntegrationTests.Fixtures; + +using System.Collections.Concurrent; +using System.Text; +using System.Text.Json; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting.Server; +using Microsoft.AspNetCore.Hosting.Server.Features; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Smtp2Go.NET.Models.Webhooks; + +/// +/// A minimal Kestrel web server that captures incoming SMTP2GO webhook payloads +/// for verification in integration tests. +/// +/// +/// +/// This fixture starts a Kestrel web server on a random available port using +/// , validates Basic Auth credentials, +/// deserializes incoming webhook payloads, and stores them for assertion by test methods. +/// +/// +/// Incoming payloads are matched to registered waiters via +/// , providing event-driven notification +/// instead of polling. +/// +/// +/// Designed to be used in conjunction with to +/// expose the local receiver to the internet for SMTP2GO webhook callbacks. +/// +/// +internal sealed class WebhookReceiverFixture : IAsyncDisposable +{ + #region Constants & Statics + + /// The path that the webhook receiver listens on. + public const string WebhookPath = "/webhook"; + + /// The health check path for tunnel reachability verification. + public const string HealthPath = "/health"; + + /// Maximum time to wait for a matching payload in . + private static readonly TimeSpan DefaultWaitTimeout = TimeSpan.FromSeconds(60); + + #endregion + + + #region Properties & Fields - Non-Public + + /// The Kestrel web application serving webhook callbacks. + private WebApplication? _app; + + /// Thread-safe collection of received webhook payloads. + private readonly ConcurrentBag _receivedPayloads = new(); + + /// Thread-safe collection of raw JSON bodies received (for debugging). + private readonly ConcurrentBag _rawBodies = new(); + + /// Registered waiters notified via when a matching payload arrives. + private readonly ConcurrentBag _waiters = new(); + + #endregion + + + #region Properties & Fields - Public + + /// Gets the local port the webhook receiver is listening on. + public int Port { get; private set; } + + /// Gets all received webhook payloads. + public IReadOnlyCollection ReceivedPayloads => _receivedPayloads.ToArray(); + + /// Gets all raw JSON bodies received (useful for debugging deserialization issues). + public IReadOnlyCollection RawBodies => _rawBodies.ToArray(); + + #endregion + + + #region Methods + + /// + /// Clears all received payloads and raw bodies. + /// Used after self-test POST verification to prevent test payloads from + /// interfering with WaitForPayloadAsync matches. + /// + public void ClearReceivedPayloads() + { + _receivedPayloads.Clear(); + _rawBodies.Clear(); + } + + + /// + /// Starts the webhook receiver on a random available port with Basic Auth validation. + /// + /// The expected Basic Auth username. + /// The expected Basic Auth password. + public async Task StartAsync(string username, string password) + { + // Encode the expected Basic Auth credentials for comparison. + var expectedAuth = Convert.ToBase64String(Encoding.UTF8.GetBytes($"{username}:{password}")); + + // Build a minimal Kestrel server on a random available port. + var builder = WebApplication.CreateSlimBuilder(); + builder.WebHost.UseUrls("http://127.0.0.1:0"); + + // Suppress Kestrel startup logging noise in test output. + builder.Logging.ClearProviders(); + + _app = builder.Build(); + + // Map the health check endpoint for tunnel reachability verification. + _app.MapGet(HealthPath, () => Results.Ok("healthy")); + + // Map the webhook endpoint using minimal API routing. + _app.MapPost(WebhookPath, async (HttpContext ctx) => + { + // Log incoming webhook request for diagnostics — BEFORE auth check so we see all requests. + Console.Error.WriteLine($"[WebhookReceiver] Received POST {ctx.Request.Path} from {ctx.Connection.RemoteIpAddress}"); + + // Validate Basic Auth header. + var authHeader = ctx.Request.Headers.Authorization.ToString(); + + if (string.IsNullOrEmpty(authHeader) + || !authHeader.StartsWith("Basic ", StringComparison.OrdinalIgnoreCase)) + { + Console.Error.WriteLine($"[WebhookReceiver] Auth REJECTED: header is empty or not Basic (got: '{authHeader}')"); + + return Results.Unauthorized(); + } + + var providedAuth = authHeader["Basic ".Length..]; + + if (providedAuth != expectedAuth) + { + Console.Error.WriteLine($"[WebhookReceiver] Auth REJECTED: credentials mismatch"); + + return Results.StatusCode(StatusCodes.Status403Forbidden); + } + + Console.Error.WriteLine($"[WebhookReceiver] Auth OK"); + + // Read and store the raw body. + using var reader = new StreamReader(ctx.Request.Body, Encoding.UTF8); + var body = await reader.ReadToEndAsync(); + _rawBodies.Add(body); + + Console.Error.WriteLine($"[WebhookReceiver] Body length: {body.Length} chars"); + + // Attempt to deserialize the webhook payload. + try + { + var payload = JsonSerializer.Deserialize(body, new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true + }); + + if (payload != null) + { + _receivedPayloads.Add(payload); + NotifyWaiters(payload); + } + } + catch (JsonException ex) + { + Console.Error.WriteLine($"[WebhookReceiver] Failed to deserialize webhook payload: {ex.Message}"); + Console.Error.WriteLine($"[WebhookReceiver] Raw body: {body}"); + } + + // Respond with 200 OK to acknowledge receipt. + return Results.Ok(); + }); + + // Start the server and discover the assigned port. + await _app.StartAsync(); + + var server = _app.Services.GetRequiredService(); + var addressFeature = server.Features.Get()!; + var address = addressFeature.Addresses.First(); + Port = new Uri(address).Port; + } + + + /// + /// Waits for a webhook payload matching the specified predicate using event-driven + /// notification via . + /// + /// + /// + /// This method first checks all previously received payloads. If no match is found, + /// a waiter is registered and notified when a matching payload arrives. A second + /// check is performed after registration to guard against the race condition where + /// a payload arrives between the initial check and the waiter registration. + /// + /// + /// A predicate to match against received payloads. + /// + /// The maximum time to wait. Defaults to (60 seconds). + /// + /// The first matching payload, or null if the timeout was reached. + public async Task WaitForPayloadAsync( + Func predicate, + TimeSpan? timeout = null) + { + // Check existing payloads first (payload may have already arrived). + var existing = _receivedPayloads.FirstOrDefault(predicate); + + if (existing != null) + return existing; + + // Register a waiter for new payloads. + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var waiter = new PayloadWaiter(predicate, tcs); + _waiters.Add(waiter); + + // Check again after registration — guards against the race condition where a payload + // arrived between the initial check and the waiter registration. + existing = _receivedPayloads.FirstOrDefault(predicate); + + if (existing != null) + { + tcs.TrySetResult(existing); + + return existing; + } + + // Wait for a matching payload with timeout. + var effectiveTimeout = timeout ?? DefaultWaitTimeout; + using var cts = new CancellationTokenSource(effectiveTimeout); + + // When the timeout fires, resolve the TCS with null so the caller isn't stuck forever. + cts.Token.Register(() => tcs.TrySetResult(null)); + + return await tcs.Task; + } + + #endregion + + + #region Methods - Non-Public + + /// + /// Notifies all registered waiters whose predicate matches the received payload. + /// + /// The received webhook payload. + private void NotifyWaiters(WebhookCallbackPayload payload) + { + foreach (var waiter in _waiters.ToArray()) + { + if (waiter.Predicate(payload)) + waiter.Tcs.TrySetResult(payload); + } + } + + #endregion + + + #region IAsyncDisposable + + /// + public async ValueTask DisposeAsync() + { + if (_app != null) + { + try + { + await _app.StopAsync(); + await _app.DisposeAsync(); + } + catch + { + // Best-effort cleanup. + } + + _app = null; + } + + // Cancel any waiters still pending so tests don't hang on disposal. + foreach (var waiter in _waiters.ToArray()) + waiter.Tcs.TrySetResult(null); + } + + #endregion + + + #region Inner Types + + /// + /// Represents a registered waiter for a webhook payload matching a predicate. + /// + /// The predicate to match against incoming payloads. + /// The to signal when a match is found. + private sealed record PayloadWaiter( + Func Predicate, + TaskCompletionSource Tcs); + + #endregion +} diff --git a/tests/Smtp2Go.NET.IntegrationTests/Helpers/Smtp2GoClientFactory.cs b/tests/Smtp2Go.NET.IntegrationTests/Helpers/Smtp2GoClientFactory.cs new file mode 100644 index 0000000..430b30c --- /dev/null +++ b/tests/Smtp2Go.NET.IntegrationTests/Helpers/Smtp2GoClientFactory.cs @@ -0,0 +1,73 @@ +namespace Smtp2Go.NET.IntegrationTests.Helpers; + +using Fixtures; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +/// +/// Factory for creating instances via the library's DI extension method. +/// Centralizes host + client construction so fixtures and tests share a single code path. +/// +internal static class Smtp2GoClientFactory +{ + /// + /// Creates an with registered via DI, + /// configured with the specified API key and test-appropriate logging. + /// + /// + /// + /// The returned host owns the DI container lifetime. Callers that need the client for + /// the duration of a test class (fixtures) should store and dispose the host. Callers + /// that need a throwaway client (e.g., invalid key tests) can use . + /// + /// + /// The SMTP2GO API key to use. + /// A tuple of the built host and the resolved client. + public static (IHost Host, ISmtp2GoClient Client) CreateHostedClient(string apiKey) + { + var settings = TestConfiguration.Settings; + + var builder = Host.CreateApplicationBuilder(); + + // Configure test-appropriate logging: concise single-line output, + // debug-level for Smtp2Go, suppress framework noise. + builder.Logging.ClearProviders(); + builder.Logging.AddSimpleConsole(o => + { + o.SingleLine = true; + o.IncludeScopes = true; + o.TimestampFormat = "HH:mm:ss.fff "; + }); + builder.Logging.SetMinimumLevel(LogLevel.Debug); + builder.Logging.AddFilter("Microsoft", LogLevel.Warning); + builder.Logging.AddFilter("System", LogLevel.Warning); + + // Use the SDK's own DI extension method — ensures the actual DI configuration is tested. + builder.Services.AddSmtp2GoWithHttp(options => + { + options.ApiKey = apiKey; + options.BaseUrl = settings.BaseUrl; + }); + + var host = builder.Build(); + var client = host.Services.GetRequiredService(); + + return (host, client); + } + + + /// + /// Creates a standalone configured with a specific API key. + /// The underlying host is not tracked — suitable for short-lived test scenarios + /// (e.g., verifying behavior with an invalid API key). + /// + /// The API key to use. + /// A configured instance. + public static ISmtp2GoClient CreateClient(string apiKey) + { + var (_, client) = CreateHostedClient(apiKey); + + return client; + } +} diff --git a/tests/Smtp2Go.NET.IntegrationTests/Helpers/TestSecretValidator.cs b/tests/Smtp2Go.NET.IntegrationTests/Helpers/TestSecretValidator.cs new file mode 100644 index 0000000..b65f5e3 --- /dev/null +++ b/tests/Smtp2Go.NET.IntegrationTests/Helpers/TestSecretValidator.cs @@ -0,0 +1,107 @@ +namespace Smtp2Go.NET.IntegrationTests.Helpers; + +using Fixtures; + +/// +/// Provides helper methods for validating that the necessary test configuration +/// and secrets are present before running integration tests. +/// +public static class TestSecretValidator +{ + #region Methods + + /// + /// Checks if a configuration value is null, empty, or still has its placeholder value. + /// + /// The configuration value to check. + /// true if the secret is missing or has a placeholder value; otherwise, false. + public static bool IsSecretMissing(string? value) + { + return string.IsNullOrWhiteSpace(value) || + value.Equals("from-user-secrets", StringComparison.OrdinalIgnoreCase); + } + + + /// + /// Gets a list of all required secrets for sandbox integration tests that are currently missing. + /// + /// A list of missing secret names. Empty if all sandbox secrets are present. + public static List GetMissingSandboxSecrets() + { + var settings = TestConfiguration.Settings; + var missing = new List(); + + if (IsSecretMissing(settings.ApiKey.Sandbox)) + missing.Add("Smtp2Go:ApiKey:Sandbox"); + + if (IsSecretMissing(settings.TestSender)) + missing.Add("Smtp2Go:TestSender"); + + return missing; + } + + + /// + /// Gets a list of all required secrets for live integration tests that are currently missing. + /// + /// + /// Webhook delivery tests also use this — webhook Basic Auth credentials are arbitrary + /// test constants (we define them when creating the webhook), not external secrets. + /// + /// A list of missing secret names. Empty if all live secrets are present. + public static List GetMissingLiveSecrets() + { + var settings = TestConfiguration.Settings; + var missing = new List(); + + if (IsSecretMissing(settings.ApiKey.Live)) + missing.Add("Smtp2Go:ApiKey:Live"); + + if (IsSecretMissing(settings.TestSender)) + missing.Add("Smtp2Go:TestSender"); + + if (IsSecretMissing(settings.TestRecipient)) + missing.Add("Smtp2Go:TestRecipient"); + + return missing; + } + + + /// + /// Asserts that all required sandbox secrets are present. + /// Fails the test with a descriptive message if any are missing. + /// + public static void AssertSandboxSecretsPresent() + { + var missing = GetMissingSandboxSecrets(); + + if (missing.Count > 0) + Assert.Fail($"Missing required secrets: {string.Join(", ", missing)}. Configure via user secrets or environment variables."); + } + + + /// + /// Asserts that all required live secrets are present. + /// Fails the test with a descriptive message if any are missing. + /// + public static void AssertLiveSecretsPresent() + { + var missing = GetMissingLiveSecrets(); + + if (missing.Count > 0) + Assert.Fail($"Missing required secrets: {string.Join(", ", missing)}. Configure via user secrets or environment variables."); + } + + + /// + /// Asserts that cloudflared is installed (on PATH or at a known install location). + /// Fails the test with a descriptive message if cloudflared is not found. + /// + public static void AssertCloudflaredInstalled() + { + if (!CloudflareTunnelManager.IsCloudflaredInstalled()) + Assert.Fail("cloudflared is not installed. Install from https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/"); + } + + #endregion +} diff --git a/tests/Smtp2Go.NET.IntegrationTests/Smtp2Go.NET.IntegrationTests.csproj b/tests/Smtp2Go.NET.IntegrationTests/Smtp2Go.NET.IntegrationTests.csproj new file mode 100644 index 0000000..f54de0e --- /dev/null +++ b/tests/Smtp2Go.NET.IntegrationTests/Smtp2Go.NET.IntegrationTests.csproj @@ -0,0 +1,37 @@ + + + + + smtp2go-net-integration-tests + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + + + + + + PreserveNewest + + + + diff --git a/tests/Smtp2Go.NET.IntegrationTests/Statistics/EmailSummaryIntegrationTests.cs b/tests/Smtp2Go.NET.IntegrationTests/Statistics/EmailSummaryIntegrationTests.cs new file mode 100644 index 0000000..c7e88d0 --- /dev/null +++ b/tests/Smtp2Go.NET.IntegrationTests/Statistics/EmailSummaryIntegrationTests.cs @@ -0,0 +1,88 @@ +namespace Smtp2Go.NET.IntegrationTests.Statistics; + +using Fixtures; +using Helpers; +using Smtp2Go.NET.Models.Statistics; + +/// +/// Integration tests for the endpoint +/// using the sandbox API key. +/// +[Trait("Category", "Integration")] +public sealed class EmailSummaryIntegrationTests : 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 EmailSummaryIntegrationTests(Smtp2GoSandboxFixture fixture) + { + _fixture = fixture; + } + + #endregion + + + #region Get Email Summary + + [Fact] + public async Task GetEmailSummary_WithNoRequest_ReturnsStatistics() + { + // Fail if sandbox secrets are not configured. + TestSecretValidator.AssertSandboxSecretsPresent(); + + // Act — Call with no request parameters for default date range. + var response = await _fixture.Client.Statistics.GetEmailSummaryAsync( + ct: TestContext.Current.CancellationToken); + + // Assert — The API should return a valid statistics response. + response.Should().NotBeNull(); + response.RequestId.Should().NotBeNullOrWhiteSpace("the API should return a request ID"); + response.Data.Should().NotBeNull("the response should contain statistics data"); + + // Statistics values should be non-negative (may be zero for sandbox accounts). + response.Data!.Emails.Should().BeGreaterThanOrEqualTo(0); + response.Data.HardBounces.Should().BeGreaterThanOrEqualTo(0); + response.Data.SoftBounces.Should().BeGreaterThanOrEqualTo(0); + } + + + [Fact] + public async Task GetEmailSummary_WithDateRange_ReturnsFilteredStatistics() + { + // Fail if sandbox secrets are not configured. + TestSecretValidator.AssertSandboxSecretsPresent(); + + // Arrange — Query for the last 7 days. + var request = new EmailSummaryRequest + { + StartDate = DateTime.UtcNow.AddDays(-7).ToString("yyyy-MM-dd"), + EndDate = DateTime.UtcNow.ToString("yyyy-MM-dd") + }; + + // Act + var response = await _fixture.Client.Statistics.GetEmailSummaryAsync( + request, + TestContext.Current.CancellationToken); + + // Assert + response.Should().NotBeNull(); + response.RequestId.Should().NotBeNullOrWhiteSpace(); + response.Data.Should().NotBeNull(); + + // Values should be non-negative. + response.Data!.Emails.Should().BeGreaterThanOrEqualTo(0); + response.Data.HardBounces.Should().BeGreaterThanOrEqualTo(0); + } + + #endregion +} diff --git a/tests/Smtp2Go.NET.IntegrationTests/Webhooks/WebhookDeliveryIntegrationTests.cs b/tests/Smtp2Go.NET.IntegrationTests/Webhooks/WebhookDeliveryIntegrationTests.cs new file mode 100644 index 0000000..3413791 --- /dev/null +++ b/tests/Smtp2Go.NET.IntegrationTests/Webhooks/WebhookDeliveryIntegrationTests.cs @@ -0,0 +1,383 @@ +namespace Smtp2Go.NET.IntegrationTests.Webhooks; + +using System.Net.Http.Headers; +using System.Text; +using Fixtures; +using Helpers; +using Smtp2Go.NET.Models.Email; +using Smtp2Go.NET.Models.Webhooks; + +/// +/// End-to-end webhook delivery integration tests using the live API key, +/// a local webhook receiver, and a Cloudflare Quick Tunnel. +/// +/// +/// +/// These tests verify the full webhook delivery pipeline: +/// +/// Start a local webhook receiver on a random port +/// Create a Cloudflare Quick Tunnel to expose the receiver publicly +/// Verify the tunnel accepts POST requests (self-test through the tunnel) +/// Register a webhook with SMTP2GO pointing to the tunnel URL +/// Send an email to trigger the webhook +/// Wait for the webhook payload to arrive at the receiver +/// Clean up: delete the webhook, stop tunnel, stop the receiver +/// +/// +/// +/// Prerequisites: cloudflared must be installed, and the live +/// API key must be configured. Webhook Basic Auth credentials are arbitrary test constants +/// defined below — they are NOT external secrets, since we define them when creating the webhook. +/// +/// +[Collection("Webhook")] +[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. + private readonly Smtp2GoLiveFixture _fixture; + + #endregion + + + #region Constructors + + /// + /// Initializes a new instance of the class. + /// + public WebhookDeliveryIntegrationTests(Smtp2GoLiveFixture fixture) + { + _fixture = fixture; + } + + #endregion + + + #region Webhook Delivery + + [Fact] + public async Task SendEmail_ReceivesDeliveredWebhook() + { + // Fail if live secrets are not configured (live key + sender + recipient). + TestSecretValidator.AssertLiveSecretsPresent(); + + // Fail if cloudflared is not installed. + TestSecretValidator.AssertCloudflaredInstalled(); + + var ct = TestContext.Current.CancellationToken; + int? webhookId = null; + + await using var receiver = new WebhookReceiverFixture(); + await using var tunnel = new CloudflareTunnelManager(); + + try + { + // Set up the full pipeline: receiver → tunnel → DNS → POST verify → webhook registration. + // Subscribe to both 'processed' and 'delivered' events to catch the earliest callback. + // 'processed' fires when SMTP2GO accepts the email; 'delivered' fires when the + // recipient MTA accepts it. + webhookId = await SetupWebhookPipelineAsync( + receiver, tunnel, + [WebhookCreateEvent.Processed, WebhookCreateEvent.Delivered], + ct); + + // Send an email to trigger the webhook. + var emailRequest = new EmailSendRequest + { + Sender = _fixture.TestSender, + To = [_fixture.TestRecipient], + Subject = $"Webhook Delivery Test - {Guid.NewGuid():N}", + TextBody = "This email triggers a webhook delivery event." + }; + + var emailResponse = await _fixture.Client.SendEmailAsync(emailRequest, ct); + emailResponse.Data.Should().NotBeNull(); + emailResponse.Data!.Succeeded.Should().BeGreaterThanOrEqualTo(1); + + Console.Error.WriteLine($"[WebhookDeliveryTest] Email sent successfully. Waiting for webhook callback..."); + + // Wait for any webhook payload to arrive. + // SMTP2GO sends one payload per event per recipient (WebhookCallbackPayload.Event is singular). + // We accept any event type — 'processed' arrives first, 'delivered' later. + // 180-second timeout accounts for email delivery delay and SMTP2GO processing time. + var payload = await receiver.WaitForPayloadAsync( + _ => true, + timeout: TimeSpan.FromSeconds(180)); + + // Diagnostic: Log all received payloads and raw bodies for debugging. + LogReceivedPayloads("WebhookDeliveryTest", receiver); + + // Assert: At minimum, we should receive a 'processed' or 'delivered' event. + payload.Should().NotBeNull("a webhook event (processed or delivered) should be received within 180 seconds"); + + // Log which event we received. + Console.Error.WriteLine($"[WebhookDeliveryTest] Received webhook event: {payload!.Event}"); + payload.Event.Should().BeOneOf(WebhookCallbackEvent.Processed, WebhookCallbackEvent.Delivered); + } + finally + { + await CleanupWebhookAsync(webhookId, ct); + } + } + + [Fact] + [Trait("Category", "Integration.LongRunning")] + public async Task SendEmail_ToNonExistentDomain_ReceivesHardBounceWebhook() + { + // Fail if live secrets are not configured (live key + sender + recipient). + TestSecretValidator.AssertLiveSecretsPresent(); + + // Fail if cloudflared is not installed. + TestSecretValidator.AssertCloudflaredInstalled(); + + var ct = TestContext.Current.CancellationToken; + int? webhookId = null; + + await using var receiver = new WebhookReceiverFixture(); + await using var tunnel = new CloudflareTunnelManager(); + + try + { + // Set up the full pipeline: receiver → tunnel → DNS → POST verify → webhook registration. + // Subscribe to 'bounce' (the subscription-level event name) to receive both + // hard and soft bounce payload events. + // Also subscribe to 'processed' to confirm SMTP2GO accepted the email. + webhookId = await SetupWebhookPipelineAsync( + receiver, tunnel, + [WebhookCreateEvent.Processed, WebhookCreateEvent.Bounce], + ct); + + // Send an email to a nonexistent mailbox on a real domain to trigger a hard bounce. + // We use @gmail.com because Gmail immediately rejects unknown recipients at SMTP level + // with "550 5.1.1 The email account that you tried to reach does not exist", which + // SMTP2GO classifies as a hard bounce. This is faster than using a non-existent domain + // (like .invalid) where DNS resolution failure causes SMTP2GO to retry for hours/days + // before eventually bouncing. + var bounceRecipient = $"smtp2go-bounce-test-{Guid.NewGuid():N}@gmail.com"; + var emailRequest = new EmailSendRequest + { + Sender = _fixture.TestSender, + To = [bounceRecipient], + Subject = $"Hard Bounce Test - {Guid.NewGuid():N}", + TextBody = "This email is sent to a non-existent domain to trigger a hard bounce webhook event." + }; + + var emailResponse = await _fixture.Client.SendEmailAsync(emailRequest, ct); + emailResponse.Data.Should().NotBeNull(); + emailResponse.Data!.Succeeded.Should().BeGreaterThanOrEqualTo(1); + + Console.Error.WriteLine($"[HardBounceTest] Email sent to {bounceRecipient}. Waiting for hard bounce webhook callback..."); + + // Wait for the bounce webhook payload to arrive. + // SMTP2GO sends "event": "bounce" (not "hard_bounced") with a separate "bounce" field + // containing "hard" or "soft". Gmail rejects unknown recipients immediately at SMTP level, + // so the bounce webhook typically arrives within seconds of the email send. + // 30-minute timeout ensures we capture the bounce even on slow runs. + var payload = await receiver.WaitForPayloadAsync( + p => p.Event == WebhookCallbackEvent.Bounce, + timeout: TimeSpan.FromMinutes(30)); + + // Diagnostic: Log all received payloads and raw bodies for debugging. + LogReceivedPayloads("HardBounceTest", receiver); + + // Assert: We should receive a bounce event. + payload.Should().NotBeNull("a bounce webhook event should be received within 30 minutes for a non-existent recipient"); + + // Assert: Verify the event type and bounce-specific fields are correctly deserialized. + Console.Error.WriteLine($"[HardBounceTest] Received webhook event: {payload!.Event}, BounceType: {payload.BounceType}, BounceContext: {payload.BounceContext}, Host: {payload.Host}"); + payload.Event.Should().Be(WebhookCallbackEvent.Bounce); + payload.BounceType.Should().Be(BounceType.Hard, "a Gmail rejection (550 5.1.1) should classify as BounceType.Hard"); + payload.BounceContext.Should().NotBeNullOrWhiteSpace("a bounce event should include the SMTP transaction context"); + payload.Host.Should().NotBeNullOrWhiteSpace("a bounce event should include the target mail server host"); + + // Assert: Common payload fields should still be populated on bounce events. + payload.EmailId.Should().NotBeNullOrWhiteSpace("the SMTP2GO email ID should be present on bounce events"); + } + finally + { + await CleanupWebhookAsync(webhookId, ct); + } + } + + #endregion + + + #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. + /// + /// + /// + /// 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 . + 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; + + // Step 3: Register the webhook with SMTP2GO. + var createRequest = new WebhookCreateRequest + { + WebhookUrl = webhookUrl, + Events = events + }; + + var createResponse = await _fixture.Client.Webhooks.CreateAsync(createRequest, ct); + createResponse.Data.Should().NotBeNull(); + + var webhookId = createResponse.Data!.WebhookId!.Value; + + Console.Error.WriteLine($"[WebhookDeliveryTest] Webhook created: ID={webhookId}, URL={webhookUrl}"); + + return webhookId; + } + + + /// + /// 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. + /// + /// A short label for the log prefix (e.g., "HardBounceTest"). + /// The webhook receiver containing the captured payloads. + private static void LogReceivedPayloads(string testName, WebhookReceiverFixture receiver) + { + Console.Error.WriteLine($"[{testName}] Received {receiver.ReceivedPayloads.Count} payload(s), {receiver.RawBodies.Count} raw body(ies)."); + + foreach (var raw in receiver.RawBodies) + Console.Error.WriteLine($"[{testName}] Raw body: {raw[..Math.Min(raw.Length, 500)]}"); + } + + + /// + /// 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 new file mode 100644 index 0000000..9345483 --- /dev/null +++ b/tests/Smtp2Go.NET.IntegrationTests/Webhooks/WebhookManagementIntegrationTests.cs @@ -0,0 +1,185 @@ +namespace Smtp2Go.NET.IntegrationTests.Webhooks; + +using Fixtures; +using Helpers; +using Smtp2Go.NET.Models.Webhooks; + +/// +/// Integration tests for webhook CRUD lifecycle operations using the live API key. +/// +/// +/// +/// These tests create, list, and delete real webhooks on the SMTP2GO account. +/// Each test cleans up after itself by deleting any webhooks it creates. +/// +/// +/// +/// 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")] +public sealed class WebhookManagementIntegrationTests : 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 WebhookManagementIntegrationTests(Smtp2GoLiveFixture fixture) + { + _fixture = fixture; + } + + #endregion + + + #region Webhook Lifecycle + + [Fact] + public async Task WebhookLifecycle_CreateListDelete_Succeeds() + { + // Fail if live secrets are not configured. + TestSecretValidator.AssertLiveSecretsPresent(); + + var ct = TestContext.Current.CancellationToken; + int? webhookId = null; + + try + { + // Step 1: Create a webhook. + var createRequest = new WebhookCreateRequest + { + WebhookUrl = $"https://webhook-test.example.com/smtp2go/{Guid.NewGuid():N}", + Events = [WebhookCreateEvent.Delivered, WebhookCreateEvent.Bounce] + }; + + var createResponse = await _fixture.Client.Webhooks.CreateAsync(createRequest, ct); + + // Assert — Create should return a valid response. + createResponse.Should().NotBeNull(); + createResponse.RequestId.Should().NotBeNullOrWhiteSpace(); + createResponse.Data.Should().NotBeNull(); + createResponse.Data!.WebhookId.Should().NotBeNull() + .And.BeGreaterThan(0, "a new webhook should receive a positive ID"); + + webhookId = createResponse.Data.WebhookId!.Value; + + + // Step 2: List webhooks and verify the created one appears. + var listResponse = await _fixture.Client.Webhooks.ListAsync(ct); + + listResponse.Should().NotBeNull(); + listResponse.Data.Should().NotBeNull(); + + // The created webhook should appear in the list. + // WebhookListResponse.Data is WebhookInfo[] (extends ApiResponse). + listResponse.Data!.Should().Contain( + w => w.WebhookId == webhookId, + "the newly created webhook should be in the list"); + + + // Step 3: Delete the webhook. + var deleteResponse = await _fixture.Client.Webhooks.DeleteAsync(webhookId!.Value, ct); + + deleteResponse.Should().NotBeNull(); + deleteResponse.RequestId.Should().NotBeNullOrWhiteSpace(); + + // Mark as cleaned up so the finally block doesn't try again. + webhookId = null; + + + // Step 4: Verify the webhook is no longer in the list. + var listAfterDelete = await _fixture.Client.Webhooks.ListAsync(ct); + var webhookIds = listAfterDelete.Data?.Select(w => w.WebhookId) ?? []; + webhookIds.Should().NotContain(createResponse.Data.WebhookId, + "the deleted webhook should no longer appear in the list"); + } + 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. + } + } + } + } + + + [Fact] + public async Task WebhookCreate_WithSpecificEvents_ConfiguresCorrectly() + { + // Fail if live secrets are not configured. + TestSecretValidator.AssertLiveSecretsPresent(); + + var ct = TestContext.Current.CancellationToken; + int? webhookId = null; + + try + { + // Arrange — Create a webhook with a specific set of event types. + // Subscribe to a representative set of subscription-level events. + // NOTE: WebhookCreateEvent values are subscription events (e.g., Bounce, Spam), + // NOT callback payload events (e.g., "hard_bounced", "spam_complaint"). + var createRequest = new WebhookCreateRequest + { + WebhookUrl = $"https://webhook-test.example.com/smtp2go/{Guid.NewGuid():N}", + Events = + [ + WebhookCreateEvent.Processed, + WebhookCreateEvent.Delivered, + WebhookCreateEvent.Bounce, + WebhookCreateEvent.Spam + ] + }; + + // Act + var createResponse = await _fixture.Client.Webhooks.CreateAsync(createRequest, ct); + + createResponse.Should().NotBeNull(); + createResponse.Data.Should().NotBeNull(); + webhookId = createResponse.Data!.WebhookId!.Value; + + // Assert — Verify via the list endpoint that the webhook has the correct events. + var listResponse = await _fixture.Client.Webhooks.ListAsync(ct); + var webhook = listResponse.Data?.FirstOrDefault(w => w.WebhookId == webhookId); + + webhook.Should().NotBeNull("the created webhook should be in the list"); + } + finally + { + // Cleanup. + if (webhookId != null) + { + try + { + await _fixture.Client.Webhooks.DeleteAsync(webhookId.Value, ct); + } + catch + { + // Best-effort cleanup. + } + } + } + } + + #endregion +} diff --git a/tests/Smtp2Go.NET.IntegrationTests/appsettings.json b/tests/Smtp2Go.NET.IntegrationTests/appsettings.json new file mode 100644 index 0000000..a668302 --- /dev/null +++ b/tests/Smtp2Go.NET.IntegrationTests/appsettings.json @@ -0,0 +1,11 @@ +{ + "Smtp2Go": { + "ApiKey": { + "Sandbox": "from-user-secrets", + "Live": "from-user-secrets" + }, + "TestSender": "from-user-secrets", + "TestRecipient": "from-user-secrets", + "BaseUrl": "https://api.smtp2go.com/v3/" + } +} diff --git a/tests/Smtp2Go.NET.UnitTests/Configuration/Smtp2GoOptionsValidatorTests.cs b/tests/Smtp2Go.NET.UnitTests/Configuration/Smtp2GoOptionsValidatorTests.cs new file mode 100644 index 0000000..1cc3c75 --- /dev/null +++ b/tests/Smtp2Go.NET.UnitTests/Configuration/Smtp2GoOptionsValidatorTests.cs @@ -0,0 +1,250 @@ +namespace Smtp2Go.NET.UnitTests.Configuration; + +using Smtp2Go.NET.Configuration; + +/// +/// Unit tests for . +/// +[Trait("Category", "Unit")] +public sealed class Smtp2GoOptionsValidatorTests +{ + #region Properties & Fields - Non-Public + + private readonly Smtp2GoOptionsValidator _validator = new(); + + #endregion + + + #region Validate - Success + + [Fact] + public void Validate_WithValidOptions_ReturnsSuccess() + { + // Arrange + var options = new Smtp2GoOptions + { + ApiKey = "api-test-key-XXXXXXXXXXXXXXXX", + BaseUrl = "https://api.smtp2go.com/v3/", + Timeout = TimeSpan.FromSeconds(30) + }; + + // Act + var result = _validator.Validate(null, options); + + // Assert + result.Succeeded.Should().BeTrue(); + } + + + [Fact] + public void Validate_WithCustomHttpBaseUrl_ReturnsSuccess() + { + // Arrange — HTTP is allowed (e.g., for local development or testing). + var options = new Smtp2GoOptions + { + ApiKey = "api-test-key", + BaseUrl = "http://localhost:5000/v3/", + Timeout = TimeSpan.FromSeconds(10) + }; + + // Act + var result = _validator.Validate(null, options); + + // Assert + result.Succeeded.Should().BeTrue(); + } + + #endregion + + + #region Validate - ApiKey + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void Validate_WithMissingApiKey_ReturnsFailed(string? apiKey) + { + // Arrange + var options = new Smtp2GoOptions + { + ApiKey = apiKey, + BaseUrl = "https://api.smtp2go.com/v3/", + Timeout = TimeSpan.FromSeconds(30) + }; + + // Act + var result = _validator.Validate(null, options); + + // Assert + result.Failed.Should().BeTrue(); + result.FailureMessage.Should().Contain("ApiKey"); + result.FailureMessage.Should().Contain("is required"); + } + + #endregion + + + #region Validate - BaseUrl + + [Fact] + public void Validate_WithEmptyBaseUrl_ReturnsFailed() + { + // Arrange + var options = new Smtp2GoOptions + { + ApiKey = "api-test-key", + BaseUrl = "", + Timeout = TimeSpan.FromSeconds(30) + }; + + // Act + var result = _validator.Validate(null, options); + + // Assert + result.Failed.Should().BeTrue(); + result.FailureMessage.Should().Contain("BaseUrl"); + result.FailureMessage.Should().Contain("is required"); + } + + + [Fact] + public void Validate_WithInvalidBaseUrl_ReturnsFailed() + { + // Arrange + var options = new Smtp2GoOptions + { + ApiKey = "api-test-key", + BaseUrl = "not-a-url", + Timeout = TimeSpan.FromSeconds(30) + }; + + // Act + var result = _validator.Validate(null, options); + + // Assert + result.Failed.Should().BeTrue(); + result.FailureMessage.Should().Contain("BaseUrl"); + result.FailureMessage.Should().Contain("valid HTTP or HTTPS URL"); + } + + + [Fact] + public void Validate_WithFtpBaseUrl_ReturnsFailed() + { + // Arrange — Only HTTP/HTTPS schemes are accepted. + var options = new Smtp2GoOptions + { + ApiKey = "api-test-key", + BaseUrl = "ftp://api.smtp2go.com/v3/", + Timeout = TimeSpan.FromSeconds(30) + }; + + // Act + var result = _validator.Validate(null, options); + + // Assert + result.Failed.Should().BeTrue(); + result.FailureMessage.Should().Contain("BaseUrl"); + result.FailureMessage.Should().Contain("valid HTTP or HTTPS URL"); + } + + #endregion + + + #region Validate - Timeout + + [Fact] + public void Validate_WithZeroTimeout_ReturnsFailed() + { + // Arrange + var options = new Smtp2GoOptions + { + ApiKey = "api-test-key", + BaseUrl = "https://api.smtp2go.com/v3/", + Timeout = TimeSpan.Zero + }; + + // Act + var result = _validator.Validate(null, options); + + // Assert + result.Failed.Should().BeTrue(); + result.FailureMessage.Should().Contain("Timeout"); + result.FailureMessage.Should().Contain("positive"); + } + + + [Fact] + public void Validate_WithNegativeTimeout_ReturnsFailed() + { + // Arrange + var options = new Smtp2GoOptions + { + ApiKey = "api-test-key", + BaseUrl = "https://api.smtp2go.com/v3/", + Timeout = TimeSpan.FromSeconds(-1) + }; + + // Act + var result = _validator.Validate(null, options); + + // Assert + result.Failed.Should().BeTrue(); + result.FailureMessage.Should().Contain("Timeout"); + } + + #endregion + + + #region Validate - Multiple Failures + + [Fact] + public void Validate_WithMultipleInvalidSettings_ReportsAllFailures() + { + // Arrange + var options = new Smtp2GoOptions + { + ApiKey = null, + BaseUrl = "not-a-url", + Timeout = TimeSpan.Zero + }; + + // Act + var result = _validator.Validate(null, options); + + // Assert + result.Failed.Should().BeTrue(); + result.FailureMessage.Should().Contain("ApiKey"); + result.FailureMessage.Should().Contain("BaseUrl"); + result.FailureMessage.Should().Contain("Timeout"); + } + + #endregion + + + #region Validate - Defaults + + [Fact] + public void DefaultOptions_HaveExpectedDefaults() + { + // Arrange & Act + var options = new Smtp2GoOptions(); + + // Assert — ApiKey defaults to null (must be configured), other properties have sensible defaults. + options.ApiKey.Should().BeNull(); + options.BaseUrl.Should().Be(Smtp2GoOptions.DefaultBaseUrl); + options.Timeout.Should().Be(TimeSpan.FromSeconds(30)); + options.Resilience.Should().NotBeNull(); + } + + + [Fact] + public void SectionName_IsSmtp2Go() + { + // Assert + Smtp2GoOptions.SectionName.Should().Be("Smtp2Go"); + } + + #endregion +} diff --git a/tests/Smtp2Go.NET.UnitTests/Models/EmailSendRequestSerializationTests.cs b/tests/Smtp2Go.NET.UnitTests/Models/EmailSendRequestSerializationTests.cs new file mode 100644 index 0000000..6267bc6 --- /dev/null +++ b/tests/Smtp2Go.NET.UnitTests/Models/EmailSendRequestSerializationTests.cs @@ -0,0 +1,153 @@ +namespace Smtp2Go.NET.UnitTests.Models; + +using System.Text.Json; +using Smtp2Go.NET.Internal; +using Smtp2Go.NET.Models.Email; + +/// +/// Verifies that serializes to JSON +/// matching the SMTP2GO API's expected format (snake_case, null omission). +/// +[Trait("Category", "Unit")] +public sealed class EmailSendRequestSerializationTests +{ + #region Serialization + + [Fact] + public void Serialize_MinimalRequest_ProducesCorrectSnakeCaseJson() + { + // Arrange + var request = new EmailSendRequest + { + Sender = "noreply@alos.app", + To = ["user@example.com"], + Subject = "Welcome", + TextBody = "Hello, World!" + }; + + // Act + var json = JsonSerializer.Serialize(request, Smtp2GoJsonDefaults.Options); + using var doc = JsonDocument.Parse(json); + var root = doc.RootElement; + + // Assert — Properties should use snake_case naming. + root.GetProperty("sender").GetString().Should().Be("noreply@alos.app"); + root.GetProperty("to").GetArrayLength().Should().Be(1); + root.GetProperty("to")[0].GetString().Should().Be("user@example.com"); + root.GetProperty("subject").GetString().Should().Be("Welcome"); + root.GetProperty("text_body").GetString().Should().Be("Hello, World!"); + } + + + [Fact] + public void Serialize_MinimalRequest_OmitsNullProperties() + { + // Arrange + var request = new EmailSendRequest + { + Sender = "noreply@alos.app", + To = ["user@example.com"], + Subject = "Test" + }; + + // Act + var json = JsonSerializer.Serialize(request, Smtp2GoJsonDefaults.Options); + using var doc = JsonDocument.Parse(json); + var root = doc.RootElement; + + // Assert — Null optional fields should not appear in the output. + root.TryGetProperty("text_body", out _).Should().BeFalse(); + root.TryGetProperty("html_body", out _).Should().BeFalse(); + root.TryGetProperty("cc", out _).Should().BeFalse(); + root.TryGetProperty("bcc", out _).Should().BeFalse(); + root.TryGetProperty("custom_headers", out _).Should().BeFalse(); + root.TryGetProperty("attachments", out _).Should().BeFalse(); + root.TryGetProperty("inlines", out _).Should().BeFalse(); + root.TryGetProperty("template_id", out _).Should().BeFalse(); + root.TryGetProperty("template_data", out _).Should().BeFalse(); + } + + + [Fact] + public void Serialize_FullRequest_IncludesAllProperties() + { + // Arrange + var request = new EmailSendRequest + { + Sender = "Alos ", + To = ["user1@example.com", "user2@example.com"], + Subject = "Full Test", + TextBody = "Plain text", + HtmlBody = "

HTML

", + Cc = ["cc@example.com"], + Bcc = ["bcc@example.com"], + CustomHeaders = + [ + new CustomHeader { Header = "X-Tag", Value = "test" } + ], + Attachments = + [ + new Attachment { Filename = "report.pdf", Fileblob = "base64data", Mimetype = "application/pdf" } + ], + TemplateId = "tmpl_123", + TemplateData = new Dictionary + { + ["user_name"] = "John" + } + }; + + // Act + var json = JsonSerializer.Serialize(request, Smtp2GoJsonDefaults.Options); + using var doc = JsonDocument.Parse(json); + var root = doc.RootElement; + + // Assert + root.GetProperty("sender").GetString().Should().Be("Alos "); + root.GetProperty("to").GetArrayLength().Should().Be(2); + root.GetProperty("subject").GetString().Should().Be("Full Test"); + root.GetProperty("text_body").GetString().Should().Be("Plain text"); + root.GetProperty("html_body").GetString().Should().Be("

HTML

"); + root.GetProperty("cc").GetArrayLength().Should().Be(1); + root.GetProperty("bcc").GetArrayLength().Should().Be(1); + root.GetProperty("custom_headers").GetArrayLength().Should().Be(1); + root.GetProperty("attachments").GetArrayLength().Should().Be(1); + root.GetProperty("template_id").GetString().Should().Be("tmpl_123"); + root.GetProperty("template_data").GetProperty("user_name").GetString().Should().Be("John"); + } + + #endregion + + + #region Deserialization + + [Fact] + public void Deserialize_EmailSendResponse_ParsesCorrectly() + { + // Arrange — Simulate a raw SMTP2GO API response. + const string json = """ + { + "request_id": "aa253464-0bd0-467a-b24b-6159dcd7be60", + "data": { + "succeeded": 1, + "failed": 0, + "failures": [], + "email_id": "1234567890abcdef" + } + } + """; + + // Act + var response = JsonSerializer.Deserialize(json, Smtp2GoJsonDefaults.Options); + + // Assert + response.Should().NotBeNull(); + response!.RequestId.Should().Be("aa253464-0bd0-467a-b24b-6159dcd7be60"); + response.Data.Should().NotBeNull(); + response.Data!.Succeeded.Should().Be(1); + response.Data.Failed.Should().Be(0); + response.Data.Failures.Should().BeEmpty(); + response.Data.EmailId.Should().Be("1234567890abcdef"); + } + + #endregion +} diff --git a/tests/Smtp2Go.NET.UnitTests/Models/WebhookPayloadDeserializationTests.cs b/tests/Smtp2Go.NET.UnitTests/Models/WebhookPayloadDeserializationTests.cs new file mode 100644 index 0000000..4e2a439 --- /dev/null +++ b/tests/Smtp2Go.NET.UnitTests/Models/WebhookPayloadDeserializationTests.cs @@ -0,0 +1,307 @@ +namespace Smtp2Go.NET.UnitTests.Models; + +using System.Text.Json; +using Smtp2Go.NET.Internal; +using Smtp2Go.NET.Models.Webhooks; + +/// +/// Verifies that SMTP2GO webhook callback payloads deserialize correctly, +/// including the custom JSON converters for +/// and . +/// +[Trait("Category", "Unit")] +public sealed class WebhookPayloadDeserializationTests +{ + #region Delivered Event + + [Fact] + public void Deserialize_DeliveredEvent_ParsesCorrectly() + { + // Arrange + const string json = """ + { + "hostname": "mail01.smtp2go.com", + "email_id": "abc-123", + "event": "delivered", + "timestamp": 1700000000, + "email": "user@example.com", + "sender": "noreply@alos.app", + "recipients_list": ["user@example.com", "user2@example.com"] + } + """; + + // Act + var payload = JsonSerializer.Deserialize(json, Smtp2GoJsonDefaults.Options); + + // Assert + payload.Should().NotBeNull(); + payload!.Hostname.Should().Be("mail01.smtp2go.com"); + payload.EmailId.Should().Be("abc-123"); + payload.Event.Should().Be(WebhookCallbackEvent.Delivered); + payload.Timestamp.Should().Be(1700000000); + payload.Email.Should().Be("user@example.com"); + payload.Sender.Should().Be("noreply@alos.app"); + payload.RecipientsList.Should().HaveCount(2); + payload.BounceType.Should().BeNull(); + payload.BounceContext.Should().BeNull(); + } + + #endregion + + + #region Bounce Events + + [Fact] + public void Deserialize_BounceEvent_HardBounce_ParsesBounceFields() + { + // Arrange — Actual SMTP2GO bounce payload format observed in live integration tests. + // SMTP2GO sends "event": "bounce" with a separate "bounce" field for hard/soft classification, + // and "context" for the SMTP transaction context. + const string json = """ + { + "email_id": "bounce-456", + "event": "bounce", + "timestamp": 1700000100, + "email": "invalid@nonexistent.com", + "from": "noreply@alos.app", + "bounce": "hard", + "context": "RCPT TO:", + "host": "gmail-smtp-in.l.google.com [209.85.233.26]" + } + """; + + // Act + var payload = JsonSerializer.Deserialize(json, Smtp2GoJsonDefaults.Options); + + // Assert + payload.Should().NotBeNull(); + payload!.Event.Should().Be(WebhookCallbackEvent.Bounce); + payload.BounceType.Should().Be(BounceType.Hard); + payload.BounceContext.Should().Be("RCPT TO:"); + payload.Host.Should().Be("gmail-smtp-in.l.google.com [209.85.233.26]"); + } + + + [Fact] + public void Deserialize_BounceEvent_SoftBounce_ParsesBounceFields() + { + // Arrange — Soft bounce in actual SMTP2GO payload format. + const string json = """ + { + "event": "bounce", + "timestamp": 1700000200, + "email": "user@example.com", + "bounce": "soft", + "context": "DATA: 452 Mailbox full" + } + """; + + // Act + var payload = JsonSerializer.Deserialize(json, Smtp2GoJsonDefaults.Options); + + // Assert + payload.Should().NotBeNull(); + payload!.Event.Should().Be(WebhookCallbackEvent.Bounce); + payload.BounceType.Should().Be(BounceType.Soft); + payload.BounceContext.Should().Be("DATA: 452 Mailbox full"); + } + + #endregion + + + #region Click Events + + [Fact] + public void Deserialize_ClickedEvent_ParsesClickFields() + { + // Arrange + const string json = """ + { + "event": "clicked", + "timestamp": 1700000300, + "email": "user@example.com", + "click_url": "https://alos.app/dashboard", + "link": "https://track.smtp2go.com/abc123" + } + """; + + // Act + var payload = JsonSerializer.Deserialize(json, Smtp2GoJsonDefaults.Options); + + // Assert + payload.Should().NotBeNull(); + payload!.Event.Should().Be(WebhookCallbackEvent.Clicked); + payload.ClickUrl.Should().Be("https://alos.app/dashboard"); + payload.Link.Should().Be("https://track.smtp2go.com/abc123"); + } + + #endregion + + + #region WebhookCallbackEvent Converter + + [Theory] + [InlineData("processed", WebhookCallbackEvent.Processed)] + [InlineData("delivered", WebhookCallbackEvent.Delivered)] + [InlineData("bounce", WebhookCallbackEvent.Bounce)] + [InlineData("opened", WebhookCallbackEvent.Opened)] + [InlineData("clicked", WebhookCallbackEvent.Clicked)] + [InlineData("unsubscribed", WebhookCallbackEvent.Unsubscribed)] + [InlineData("spam_complaint", WebhookCallbackEvent.SpamComplaint)] + public void CallbackEventConverter_DeserializesKnownEvents(string jsonValue, WebhookCallbackEvent expected) + { + // Arrange + var json = $$"""{"event": "{{jsonValue}}", "timestamp": 0}"""; + + // Act + var payload = JsonSerializer.Deserialize(json, Smtp2GoJsonDefaults.Options); + + // Assert + payload.Should().NotBeNull(); + payload!.Event.Should().Be(expected); + } + + + [Theory] + [InlineData("some_future_event")] + [InlineData("hard_bounced")] + [InlineData("soft_bounced")] + public void CallbackEventConverter_DeserializesUnknownEvent_AsUnknown(string jsonValue) + { + // Arrange — The API may introduce new event types in the future. + // Also verifies that the removed legacy values ("hard_bounced", "soft_bounced") + // now correctly fall through to Unknown instead of being mapped to dead enum values. + var json = $$"""{"event": "{{jsonValue}}", "timestamp": 0}"""; + + // Act + var payload = JsonSerializer.Deserialize(json, Smtp2GoJsonDefaults.Options); + + // Assert + payload.Should().NotBeNull(); + payload!.Event.Should().Be(WebhookCallbackEvent.Unknown); + } + + + [Theory] + [InlineData(WebhookCallbackEvent.Processed, "processed")] + [InlineData(WebhookCallbackEvent.Delivered, "delivered")] + [InlineData(WebhookCallbackEvent.Bounce, "bounce")] + [InlineData(WebhookCallbackEvent.Opened, "opened")] + [InlineData(WebhookCallbackEvent.Clicked, "clicked")] + [InlineData(WebhookCallbackEvent.Unsubscribed, "unsubscribed")] + [InlineData(WebhookCallbackEvent.SpamComplaint, "spam_complaint")] + public void CallbackEventConverter_SerializesToSnakeCase(WebhookCallbackEvent value, string expected) + { + // Arrange — Serialize via a wrapper to trigger the converter. + var options = new JsonSerializerOptions(); + options.Converters.Add(new WebhookCallbackEventJsonConverter()); + + // Act + var json = JsonSerializer.Serialize(value, options); + + // Assert — The value should be a quoted snake_case string. + json.Should().Be($"\"{expected}\""); + } + + #endregion + + + #region BounceType Converter + + [Theory] + [InlineData("hard", BounceType.Hard)] + [InlineData("soft", BounceType.Soft)] + public void BounceTypeConverter_DeserializesKnownTypes(string jsonValue, BounceType expected) + { + // Arrange — The "bounce" field contains the bounce classification (hard/soft). + var json = $$"""{"event": "bounce", "timestamp": 0, "bounce": "{{jsonValue}}"}"""; + + // Act + var payload = JsonSerializer.Deserialize(json, Smtp2GoJsonDefaults.Options); + + // Assert + payload.Should().NotBeNull(); + payload!.BounceType.Should().Be(expected); + } + + + [Fact] + public void BounceTypeConverter_DeserializesUnknownType_AsUnknown() + { + // Arrange + const string json = """{"event": "bounce", "timestamp": 0, "bounce": "future_type"}"""; + + // Act + var payload = JsonSerializer.Deserialize(json, Smtp2GoJsonDefaults.Options); + + // Assert + payload.Should().NotBeNull(); + payload!.BounceType.Should().Be(BounceType.Unknown); + } + + + [Fact] + public void BounceTypeConverter_DeserializesNull_AsNull() + { + // Arrange — Non-bounce events have no "bounce" field. + const string json = """{"event": "delivered", "timestamp": 0}"""; + + // Act + var payload = JsonSerializer.Deserialize(json, Smtp2GoJsonDefaults.Options); + + // Assert + payload.Should().NotBeNull(); + payload!.BounceType.Should().BeNull(); + } + + #endregion + + + #region WebhookCreateEvent Converter + + [Theory] + [InlineData(WebhookCreateEvent.Processed, "processed")] + [InlineData(WebhookCreateEvent.Delivered, "delivered")] + [InlineData(WebhookCreateEvent.Bounce, "bounce")] + [InlineData(WebhookCreateEvent.Open, "open")] + [InlineData(WebhookCreateEvent.Click, "click")] + [InlineData(WebhookCreateEvent.Spam, "spam")] + [InlineData(WebhookCreateEvent.Unsubscribe, "unsubscribe")] + [InlineData(WebhookCreateEvent.Resubscribe, "resubscribe")] + [InlineData(WebhookCreateEvent.Reject, "reject")] + public void CreateEventConverter_SerializesToApiStrings(WebhookCreateEvent value, string expected) + { + // Arrange — WebhookCreateEvent has [JsonConverter] on the enum type itself, + // so it auto-serializes without additional options. + // Act + var json = JsonSerializer.Serialize(value); + + // Assert — The value should be a quoted subscription event string. + json.Should().Be($"\"{expected}\""); + } + + + [Theory] + [InlineData("processed", WebhookCreateEvent.Processed)] + [InlineData("delivered", WebhookCreateEvent.Delivered)] + [InlineData("bounce", WebhookCreateEvent.Bounce)] + [InlineData("open", WebhookCreateEvent.Open)] + [InlineData("click", WebhookCreateEvent.Click)] + [InlineData("spam", WebhookCreateEvent.Spam)] + [InlineData("unsubscribe", WebhookCreateEvent.Unsubscribe)] + [InlineData("resubscribe", WebhookCreateEvent.Resubscribe)] + [InlineData("reject", WebhookCreateEvent.Reject)] + public void CreateEventConverter_DeserializesFromApiStrings(string jsonValue, WebhookCreateEvent expected) + { + // Arrange + var json = $"\"{jsonValue}\""; + + // Act + var result = JsonSerializer.Deserialize(json); + + // Assert + result.Should().Be(expected); + } + + #endregion +} diff --git a/tests/Smtp2Go.NET.UnitTests/Smtp2Go.NET.UnitTests.csproj b/tests/Smtp2Go.NET.UnitTests/Smtp2Go.NET.UnitTests.csproj new file mode 100644 index 0000000..dde3dad --- /dev/null +++ b/tests/Smtp2Go.NET.UnitTests/Smtp2Go.NET.UnitTests.csproj @@ -0,0 +1,38 @@ + + + + + smtp2go-net-tests-00000000-0000-0000-0000-000000000000 + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + + + + + + + + + + + PreserveNewest + + + + diff --git a/tests/Smtp2Go.NET.UnitTests/Smtp2GoClientTests.cs b/tests/Smtp2Go.NET.UnitTests/Smtp2GoClientTests.cs new file mode 100644 index 0000000..a79a525 --- /dev/null +++ b/tests/Smtp2Go.NET.UnitTests/Smtp2GoClientTests.cs @@ -0,0 +1,395 @@ +namespace Smtp2Go.NET.UnitTests; + +using System.Net; +using System.Text.Json; +using Smtp2Go.NET.Configuration; +using Smtp2Go.NET.Exceptions; +using Smtp2Go.NET.Models.Email; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; + +/// +/// Unit tests for . +/// +[Trait("Category", "Unit")] +public sealed class Smtp2GoClientTests +{ + #region Constants & Statics + + /// The test API key used across all tests. + private const string TestApiKey = "api-test-key-for-unit-tests"; + + /// The API key header name set by the client. + private const string ApiKeyHeaderName = "X-Smtp2go-Api-Key"; + + #endregion + + + #region Constructor & Configuration + + [Fact] + public void Constructor_SetsBaseAddress_FromOptions() + { + // Arrange & Act + var (client, httpClient, _) = CreateClient(); + + // Assert + httpClient.BaseAddress.Should().NotBeNull(); + httpClient.BaseAddress!.ToString().Should().Be("https://api.smtp2go.com/v3/"); + } + + + [Fact] + public void Constructor_SetsApiKeyHeader_FromOptions() + { + // Arrange & Act + var (client, httpClient, _) = CreateClient(); + + // Assert + httpClient.DefaultRequestHeaders.Contains(ApiKeyHeaderName).Should().BeTrue(); + httpClient.DefaultRequestHeaders.GetValues(ApiKeyHeaderName).Should().ContainSingle() + .Which.Should().Be(TestApiKey); + } + + + [Fact] + public void Constructor_SetsTimeout_FromOptions() + { + // Arrange & Act + var (client, httpClient, _) = CreateClient(timeout: TimeSpan.FromSeconds(45)); + + // Assert + httpClient.Timeout.Should().Be(TimeSpan.FromSeconds(45)); + } + + + [Fact] + public void Constructor_DoesNotOverrideBaseAddress_WhenAlreadySet() + { + // Arrange — Pre-set the base address on the HttpClient. + var existingBaseAddress = new Uri("https://custom.api.test/v3/"); + var handler = new MockHttpMessageHandler( + new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent("{}") }); + + var httpClient = new HttpClient(handler) + { + BaseAddress = existingBaseAddress + }; + + var options = CreateOptions(); + + // Act + var client = new Smtp2GoClient( + httpClient, + Options.Create(options), + NullLogger.Instance); + + // Assert — BaseAddress should remain the pre-set value. + httpClient.BaseAddress.Should().Be(existingBaseAddress); + } + + #endregion + + + #region SendEmailAsync + + [Fact] + public async Task SendEmailAsync_WithValidRequest_ReturnsResponse() + { + // Arrange + var responseJson = JsonSerializer.Serialize(new + { + request_id = "req-123", + data = new + { + succeeded = 1, + failed = 0, + email_id = "email-abc-123" + } + }); + + var (client, _, _) = CreateClient( + new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(responseJson, System.Text.Encoding.UTF8, "application/json") + }); + + var request = new EmailSendRequest + { + Sender = "test@example.com", + To = ["recipient@example.com"], + Subject = "Test", + TextBody = "Hello" + }; + + // Act + var response = await client.SendEmailAsync(request, TestContext.Current.CancellationToken); + + // Assert + response.Should().NotBeNull(); + response.RequestId.Should().Be("req-123"); + response.Data.Should().NotBeNull(); + response.Data!.Succeeded.Should().Be(1); + response.Data.Failed.Should().Be(0); + response.Data.EmailId.Should().Be("email-abc-123"); + } + + + [Fact] + public async Task SendEmailAsync_WithNullRequest_ThrowsArgumentNullException() + { + // Arrange + var (client, _, _) = CreateClient(); + + // Act + var act = async () => await client.SendEmailAsync(null!); + + // Assert + await act.Should().ThrowAsync(); + } + + + [Fact] + public async Task SendEmailAsync_WithApiError_ThrowsSmtp2GoApiException() + { + // Arrange + var errorJson = JsonSerializer.Serialize(new + { + request_id = "req-error-456", + data = new + { + error = "Invalid API key", + error_code = "E_ApiKey" + } + }); + + var (client, _, _) = CreateClient( + new HttpResponseMessage(HttpStatusCode.Unauthorized) + { + Content = new StringContent(errorJson, System.Text.Encoding.UTF8, "application/json") + }); + + var request = new EmailSendRequest + { + Sender = "test@example.com", + To = ["recipient@example.com"], + Subject = "Test", + TextBody = "Hello" + }; + + // Act + var act = async () => await client.SendEmailAsync(request); + + // Assert + var ex = (await act.Should().ThrowAsync()).Which; + ex.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + ex.ErrorMessage.Should().Be("Invalid API key"); + ex.RequestId.Should().Be("req-error-456"); + } + + + [Fact] + public async Task SendEmailAsync_WithServerError_ThrowsSmtp2GoApiException() + { + // Arrange + var (client, _, _) = CreateClient( + new HttpResponseMessage(HttpStatusCode.InternalServerError) + { + Content = new StringContent("Internal Server Error", System.Text.Encoding.UTF8, "text/plain") + }); + + var request = new EmailSendRequest + { + Sender = "test@example.com", + To = ["recipient@example.com"], + Subject = "Test", + TextBody = "Hello" + }; + + // Act + var act = async () => await client.SendEmailAsync(request); + + // Assert + var ex = (await act.Should().ThrowAsync()).Which; + ex.StatusCode.Should().Be(HttpStatusCode.InternalServerError); + } + + #endregion + + + #region Statistics.GetEmailSummaryAsync + + [Fact] + public async Task Statistics_GetEmailSummaryAsync_WithNoRequest_ReturnsResponse() + { + // Arrange + var responseJson = JsonSerializer.Serialize(new + { + request_id = "req-summary-789", + data = new + { + email_count = 100, + hardbounces = 3, + softbounces = 2, + opens = 50, + clicks = 10 + } + }); + + var (client, _, _) = CreateClient( + new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(responseJson, System.Text.Encoding.UTF8, "application/json") + }); + + // Act — Statistics is now a sub-client module. + var response = await client.Statistics.GetEmailSummaryAsync(ct: TestContext.Current.CancellationToken); + + // Assert + response.Should().NotBeNull(); + response.RequestId.Should().Be("req-summary-789"); + response.Data.Should().NotBeNull(); + response.Data!.Emails.Should().Be(100); + response.Data.HardBounces.Should().Be(3); + response.Data.SoftBounces.Should().Be(2); + } + + #endregion + + + #region Sub-Client Properties + + [Fact] + public void Webhooks_ReturnsNonNull() + { + // Arrange + var (client, _, _) = CreateClient(); + + // Act + var webhooks = client.Webhooks; + + // Assert + webhooks.Should().NotBeNull(); + } + + + [Fact] + public void Webhooks_ReturnsSameInstanceOnMultipleCalls() + { + // Arrange — The webhook sub-client is lazily created and should be reused. + var (client, _, _) = CreateClient(); + + // Act + var first = client.Webhooks; + var second = client.Webhooks; + + // Assert + first.Should().BeSameAs(second); + } + + + [Fact] + public void Statistics_ReturnsNonNull() + { + // Arrange + var (client, _, _) = CreateClient(); + + // Act + var statistics = client.Statistics; + + // Assert + statistics.Should().NotBeNull(); + } + + + [Fact] + public void Statistics_ReturnsSameInstanceOnMultipleCalls() + { + // Arrange — The statistics sub-client is lazily created and should be reused. + var (client, _, _) = CreateClient(); + + // Act + var first = client.Statistics; + var second = client.Statistics; + + // Assert + first.Should().BeSameAs(second); + } + + #endregion + + + #region Helpers + + /// + /// Creates an with a mock HTTP message handler. + /// + /// + /// The HTTP response to return from all requests. If null, a default 200 OK response is used. + /// + /// The timeout to configure. Defaults to 30 seconds. + /// A tuple of the client, the underlying HttpClient (for header/configuration assertions), and the handler. + private static (ISmtp2GoClient Client, HttpClient HttpClient, MockHttpMessageHandler Handler) CreateClient( + HttpResponseMessage? response = null, + TimeSpan? timeout = null) + { + var handler = new MockHttpMessageHandler( + response ?? new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("{\"request_id\":\"test\",\"data\":{}}", System.Text.Encoding.UTF8, + "application/json") + }); + + var httpClient = new HttpClient(handler); + var options = CreateOptions(timeout); + + var client = new Smtp2GoClient( + httpClient, + Options.Create(options), + NullLogger.Instance); + + return (client, httpClient, handler); + } + + + /// + /// Creates a valid for testing. + /// + private static Smtp2GoOptions CreateOptions(TimeSpan? timeout = null) + { + return new Smtp2GoOptions + { + ApiKey = TestApiKey, + BaseUrl = "https://api.smtp2go.com/v3/", + Timeout = timeout ?? TimeSpan.FromSeconds(30) + }; + } + + #endregion + + + #region Mock HTTP Message Handler + + /// + /// A simple mock that returns a predefined response. + /// + private sealed class MockHttpMessageHandler(HttpResponseMessage response) : HttpMessageHandler + { + /// + /// Gets the last request that was sent through this handler. + /// + public HttpRequestMessage? LastRequest { get; private set; } + + /// + protected override Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) + { + LastRequest = request; + + return Task.FromResult(response); + } + } + + #endregion +} diff --git a/tests/Smtp2Go.NET.UnitTests/appsettings.json b/tests/Smtp2Go.NET.UnitTests/appsettings.json new file mode 100644 index 0000000..6a744f1 --- /dev/null +++ b/tests/Smtp2Go.NET.UnitTests/appsettings.json @@ -0,0 +1,7 @@ +{ + "Smtp2Go": { + "ApiKey": "api-test-key-for-unit-tests", + "BaseUrl": "https://api.smtp2go.com/v3/", + "Timeout": "00:00:30" + } +} From 835511205f995fdebd426e2ebe91e1a02b087c39 Mon Sep 17 00:00:00 2001 From: Alexis <3876562+alexis-@users.noreply.github.com> Date: Fri, 6 Feb 2026 17:20:10 +0100 Subject: [PATCH 2/3] CI runs on dev --- .github/workflows/CI.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index bdfb10d..e8d29f0 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -12,9 +12,9 @@ name: CI on: push: - branches: [ main ] + branches: [ main, dev ] pull_request: - branches: [ main ] + branches: [ main, dev ] # Cancel in-progress runs when a new run is triggered for the same branch/PR. # This prevents resource conflicts and saves CI minutes. From 0c257bfe4735cf6b0cf330696097261a2ac71b1e Mon Sep 17 00:00:00 2001 From: Alexis <3876562+alexis-@users.noreply.github.com> Date: Fri, 6 Feb 2026 17:22:29 +0100 Subject: [PATCH 3/3] CI workflow fix --- .github/workflows/CI.yml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index e8d29f0..82e6e6e 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -55,6 +55,10 @@ jobs: with: name: build-output-Debug + - name: Restore execute permissions + # upload-artifact/download-artifact strips POSIX execute bits. + run: chmod +x ./tests/Smtp2Go.NET.UnitTests/bin/Debug/net10.0/Smtp2Go.NET.UnitTests + - name: Run unit tests # xUnit v3 test projects are standalone executables — dotnet test does not discover them. run: ./tests/Smtp2Go.NET.UnitTests/bin/Debug/net10.0/Smtp2Go.NET.UnitTests @@ -83,6 +87,10 @@ jobs: with: name: build-output-Debug + - name: Restore execute permissions + # 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). # xUnit v3 uses -trait- (with trailing dash) to exclude tests by trait.