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..82e6e6e --- /dev/null +++ b/.github/workflows/CI.yml @@ -0,0 +1,110 @@ +# 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, dev ] + pull_request: + 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. +# 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: 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 + + # 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: 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. + # 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 0000000..775c6fe Binary files /dev/null and b/Smtp2Go.NET.pub differ 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 0000000..94dea30 Binary files /dev/null and b/Smtp2Go.NET.snk differ 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" + } +}