From 5787fb95332e3d2d3d9b210af20e4b8124371b0a Mon Sep 17 00:00:00 2001 From: Mitch Chimwemwe Chanza Date: Thu, 26 Mar 2026 15:09:43 +0200 Subject: [PATCH 01/10] feat(wangamail-csharp, wangapayfast-csharp): add initial implementations for email and payment processing clients - Introduced `GraphMailClient` and `GraphMailClientBuilder` for sending emails via Microsoft Graph API. - Added `PayFastClient` and `PayFastClientBuilder` for building PayFast checkout payloads and signatures. - Implemented models for email and payment requests, including validation and error handling. - Created unit tests for both clients to ensure functionality and error handling. - Added README files for both libraries to provide usage instructions and features. --- .github/workflows/wangamail-csharp.yml | 410 ++++++++++++++++++ .github/workflows/wangapayfast-csharp.yml | 83 ++++ .github/workflows/wangapayfast-rs.yml | 76 +++- wangamail-csharp/GraphMailClient.cs | 48 ++ wangamail-csharp/GraphMailClientBuilder.cs | 84 ++++ wangamail-csharp/GraphModels.cs | 95 ++++ wangamail-csharp/README.md | 60 +++ wangamail-csharp/TokenProvider.cs | 92 ++++ wangamail-csharp/WangaMail.CSharp.csproj | 20 + wangamail-csharp/WangaMailException.cs | 12 + .../GraphModelsTests.cs | 39 ++ .../WangaMail.CSharp.Tests.csproj | 19 + wangapayfast-csharp/PayFastClient.cs | 201 +++++++++ wangapayfast-csharp/PayFastClientBuilder.cs | 46 ++ wangapayfast-csharp/PayFastModels.cs | 101 +++++ wangapayfast-csharp/README.md | 47 ++ .../WangaPayFast.CSharp.csproj | 20 + wangapayfast-csharp/WangaPayFastException.cs | 12 + .../PayFastClientTests.cs | 62 +++ .../WangaPayFast.CSharp.Tests.csproj | 19 + wangapayfast-rs/README.md | 34 +- wangapayfast-rs/src/error.rs | 6 +- wangapayfast-rs/src/lib.rs | 2 + wangapayfast-rs/src/onsite.rs | 5 + 24 files changed, 1580 insertions(+), 13 deletions(-) create mode 100644 .github/workflows/wangamail-csharp.yml create mode 100644 .github/workflows/wangapayfast-csharp.yml create mode 100644 wangamail-csharp/GraphMailClient.cs create mode 100644 wangamail-csharp/GraphMailClientBuilder.cs create mode 100644 wangamail-csharp/GraphModels.cs create mode 100644 wangamail-csharp/README.md create mode 100644 wangamail-csharp/TokenProvider.cs create mode 100644 wangamail-csharp/WangaMail.CSharp.csproj create mode 100644 wangamail-csharp/WangaMailException.cs create mode 100644 wangamail-csharp/tests/WangaMail.CSharp.Tests/GraphModelsTests.cs create mode 100644 wangamail-csharp/tests/WangaMail.CSharp.Tests/WangaMail.CSharp.Tests.csproj create mode 100644 wangapayfast-csharp/PayFastClient.cs create mode 100644 wangapayfast-csharp/PayFastClientBuilder.cs create mode 100644 wangapayfast-csharp/PayFastModels.cs create mode 100644 wangapayfast-csharp/README.md create mode 100644 wangapayfast-csharp/WangaPayFast.CSharp.csproj create mode 100644 wangapayfast-csharp/WangaPayFastException.cs create mode 100644 wangapayfast-csharp/tests/WangaPayFast.CSharp.Tests/PayFastClientTests.cs create mode 100644 wangapayfast-csharp/tests/WangaPayFast.CSharp.Tests/WangaPayFast.CSharp.Tests.csproj diff --git a/.github/workflows/wangamail-csharp.yml b/.github/workflows/wangamail-csharp.yml new file mode 100644 index 0000000..a5cf349 --- /dev/null +++ b/.github/workflows/wangamail-csharp.yml @@ -0,0 +1,410 @@ +name: wangamail-csharp + +on: + pull_request: + types: [opened, synchronize, reopened] + paths: + - 'wangamail-csharp/**' + - '.github/workflows/wangamail-csharp.yml' + push: + branches: [main] + paths: + - 'wangamail-csharp/**' + - '.github/workflows/wangamail-csharp.yml' + workflow_dispatch: + +jobs: + format: + name: Format + runs-on: ubuntu-latest + if: github.event_name == 'pull_request' + steps: + - uses: actions/checkout@v4 + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '8.0.x' + - name: Restore + run: | + dotnet restore wangamail-csharp/WangaMail.CSharp.csproj + dotnet restore wangamail-csharp/tests/WangaMail.CSharp.Tests/WangaMail.CSharp.Tests.csproj + - name: Check formatting + run: dotnet format wangamail-csharp/WangaMail.CSharp.csproj --verify-no-changes --verbosity minimal + - name: Job summary + if: always() + run: | + echo "## Format" >> $GITHUB_STEP_SUMMARY + echo "**Status:** ${{ job.status }}" >> $GITHUB_STEP_SUMMARY + + build: + name: Build + runs-on: ubuntu-latest + needs: format + if: github.event_name == 'pull_request' + steps: + - uses: actions/checkout@v4 + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '8.0.x' + - name: Restore + run: | + dotnet restore wangamail-csharp/WangaMail.CSharp.csproj + dotnet restore wangamail-csharp/tests/WangaMail.CSharp.Tests/WangaMail.CSharp.Tests.csproj + - name: Build + run: dotnet build wangamail-csharp/WangaMail.CSharp.csproj -c Release --no-restore + - name: Job summary + if: always() + run: | + echo "## Build" >> $GITHUB_STEP_SUMMARY + echo "**Status:** ${{ job.status }}" >> $GITHUB_STEP_SUMMARY + + test: + name: Test + runs-on: ubuntu-latest + needs: build + if: github.event_name == 'pull_request' + steps: + - uses: actions/checkout@v4 + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '8.0.x' + - name: Restore + run: | + dotnet restore wangamail-csharp/WangaMail.CSharp.csproj + dotnet restore wangamail-csharp/tests/WangaMail.CSharp.Tests/WangaMail.CSharp.Tests.csproj + - name: Test + run: dotnet test wangamail-csharp/tests/WangaMail.CSharp.Tests/WangaMail.CSharp.Tests.csproj -c Release --no-build + - name: Job summary + if: always() + run: | + echo "## Test" >> $GITHUB_STEP_SUMMARY + echo "**Status:** ${{ job.status }}" >> $GITHUB_STEP_SUMMARY + + version-bump: + name: Bump Version + runs-on: ubuntu-latest + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + outputs: + version: ${{ steps.bump.outputs.new_version }} + permissions: + contents: write + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Get current version + id: current + run: | + python3 - <<'PY' + import os + import re + from pathlib import Path + + p = Path("wangamail-csharp/WangaMail.CSharp.csproj") + text = p.read_text(encoding="utf-8") + m = re.search(r"(\d+\.\d+\.\d+)", text) + if not m: + raise SystemExit("Error: Failed to extract Version from WangaMail.CSharp.csproj") + version = m.group(1) + with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as f: + f.write(f"version={version}\n") + print(f"Current version: {version}") + PY + + - name: Bump patch version + id: bump + run: | + V="${{ steps.current.outputs.version }}" + IFS='.' read -r MAJOR MINOR PATCH <<< "$V" + PATCH=$((PATCH + 1)) + NEW="${MAJOR}.${MINOR}.${PATCH}" + echo "new_version=$NEW" >> $GITHUB_OUTPUT + python3 - <\d+\.\d+\.\d+", + "${NEW}", + text, + count=1, + ) + if n != 1: + raise SystemExit("Error: Failed to update Version in WangaMail.CSharp.csproj") + p.write_text(text2, encoding="utf-8") + print("New version: ${NEW}") + PY + + - name: Configure Git + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + - name: Commit and push version + run: | + git add wangamail-csharp/WangaMail.CSharp.csproj + if git diff --staged --quiet; then + echo "No version change to commit" + else + git commit -m "chore(wangamail-csharp): bump version to ${{ steps.bump.outputs.new_version }} [skip ci]" + git fetch origin main || true + git pull origin main --rebase || git pull origin main --no-rebase || true + git push origin main || git push origin main --force-with-lease + fi + + - name: Job summary + if: always() + run: | + echo "## Bump Version" >> $GITHUB_STEP_SUMMARY + echo "**New version:** ${{ steps.bump.outputs.new_version }}" >> $GITHUB_STEP_SUMMARY + + build-release: + name: Build + runs-on: ubuntu-latest + needs: version-bump + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Pull latest (version bump) + run: git pull origin main || true + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '8.0.x' + + - name: Restore + run: dotnet restore wangamail-csharp/WangaMail.CSharp.csproj + + - name: Build release + run: dotnet build wangamail-csharp/WangaMail.CSharp.csproj -c Release --no-restore + + - name: Job summary + if: always() + run: | + echo "## Build" >> $GITHUB_STEP_SUMMARY + echo "**Status:** ${{ job.status }}" >> $GITHUB_STEP_SUMMARY + + get-version: + name: Get Version + runs-on: ubuntu-latest + needs: [version-bump, build-release] + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + outputs: + version: ${{ steps.version.outputs.version }} + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Pull latest + run: git pull origin main || true + + - name: Get version + id: version + run: | + V=$(python3 - <<'PY' + import re + from pathlib import Path + text = Path("wangamail-csharp/WangaMail.CSharp.csproj").read_text(encoding="utf-8") + m = re.search(r"(\d+\.\d+\.\d+)", text) + print(m.group(1) if m else "") + PY + ) + if [ -z "$V" ]; then + echo "Error: Failed to extract Version from WangaMail.CSharp.csproj" + exit 1 + fi + echo "version=$V" >> $GITHUB_OUTPUT + echo "Version: $V" + + - name: Job summary + if: always() + run: | + echo "## Get Version" >> $GITHUB_STEP_SUMMARY + echo "**Version:** ${{ steps.version.outputs.version }}" >> $GITHUB_STEP_SUMMARY + + publish: + name: Publish to NuGet + runs-on: ubuntu-latest + needs: get-version + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + permissions: + contents: read + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Pull latest + run: git pull origin main || true + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '8.0.x' + + - name: Restore + run: dotnet restore wangamail-csharp/WangaMail.CSharp.csproj + + - name: Pack + run: dotnet pack wangamail-csharp/WangaMail.CSharp.csproj -c Release --no-restore + + - name: Publish to NuGet + env: + NUGET_API_KEY: ${{ secrets.NUGET_API_KEY }} + run: | + dotnet nuget push "wangamail-csharp/bin/Release/*.nupkg" \ + --api-key "$NUGET_API_KEY" \ + --source "https://api.nuget.org/v3/index.json" \ + --skip-duplicate + + - name: Job summary + if: always() + run: | + echo "## Publish" >> $GITHUB_STEP_SUMMARY + echo "**Status:** ${{ job.status }}" >> $GITHUB_STEP_SUMMARY + echo "**Version:** ${{ needs.get-version.outputs.version }}" >> $GITHUB_STEP_SUMMARY + + release: + name: Create Release + runs-on: ubuntu-latest + needs: [get-version, publish] + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + permissions: + contents: write + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Create tag and release + uses: softprops/action-gh-release@v2 + with: + tag_name: wangamail-csharp-v${{ needs.get-version.outputs.version }} + name: wangamail-csharp v${{ needs.get-version.outputs.version }} + body: | + ## wangamail-csharp ${{ needs.get-version.outputs.version }} + Send email via Microsoft Graph API using app registration (client credentials). + - [NuGet](https://www.nuget.org/packages/wangamail-csharp) + draft: false + prerelease: false + + - name: Job summary + if: always() + run: | + echo "## Release" >> $GITHUB_STEP_SUMMARY + echo "**Status:** ${{ job.status }}" >> $GITHUB_STEP_SUMMARY + echo "**Tag:** wangamail-csharp-v${{ needs.get-version.outputs.version }}" >> $GITHUB_STEP_SUMMARY + + monitor-failures: + name: Monitor Failed Jobs + runs-on: ubuntu-latest + if: always() && failure() + needs: [format, build, test, version-bump, build-release, get-version, publish, release] + permissions: + contents: read + issues: write + actions: read + steps: + - uses: actions/checkout@v4 + - name: Create failure issue + uses: actions/github-script@v7 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const { data: run } = await github.rest.actions.getWorkflowRun({ + owner: context.repo.owner, + repo: context.repo.repo, + run_id: context.runId + }); + const { data: jobsData } = await github.rest.actions.listJobsForWorkflowRun({ + owner: context.repo.owner, + repo: context.repo.repo, + run_id: context.runId + }); + const failedJobs = jobsData.jobs.filter(job => + job.conclusion === 'failure' || + job.conclusion === 'cancelled' || + job.conclusion === 'timed_out' + ); + if (failedJobs.length === 0) { + return; + } + + const branch = context.ref.replace('refs/heads/', ''); + const title = `🚨 CI/CD Failure: wangamail-csharp - ${branch} branch`; + let body = `## CI/CD Pipeline Failure\n\n`; + body += `**Workflow:** [wangamail-csharp](${run.html_url})\n`; + body += `**Branch:** \`${branch}\`\n`; + body += `**Commit:** [${context.sha.substring(0, 7)}](${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/commit/${context.sha})\n\n`; + body += `### Failed Jobs (${failedJobs.length})\n\n`; + for (const job of failedJobs) { + body += `- **${job.name}**: ${job.conclusion} ([logs](${job.html_url}))\n`; + } + + const milestoneTitle = 'CI/CD Failures'; + let milestoneNumber = null; + try { + const { data: milestones } = await github.rest.issues.listMilestones({ + owner: context.repo.owner, + repo: context.repo.repo, + state: 'open' + }); + let milestone = milestones.find(m => m.title === milestoneTitle); + if (!milestone) { + const { data: created } = await github.rest.issues.createMilestone({ + owner: context.repo.owner, + repo: context.repo.repo, + title: milestoneTitle, + description: 'Track all CI/CD pipeline failures across the repository', + state: 'open' + }); + milestone = created; + } + milestoneNumber = milestone.number; + } catch (e) { + console.log('Milestone setup failed:', e.message); + } + + const { data: issues } = await github.rest.issues.listForRepo({ + owner: context.repo.owner, + repo: context.repo.repo, + state: 'open', + labels: 'ci-failure,wangamail-csharp' + }); + const existing = issues.find(issue => issue.title === title); + if (existing) { + await github.rest.issues.update({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: existing.number, + body, + state: 'open', + milestone: milestoneNumber + }); + } else { + await github.rest.issues.create({ + owner: context.repo.owner, + repo: context.repo.repo, + title, + body, + labels: ['ci-failure', 'wangamail-csharp', 'bug', 'priority-high'], + milestone: milestoneNumber + }); + } + + - name: Job summary + if: always() + run: | + echo "## Monitor Failed Jobs" >> $GITHUB_STEP_SUMMARY + echo "**Status:** ${{ job.status }}" >> $GITHUB_STEP_SUMMARY diff --git a/.github/workflows/wangapayfast-csharp.yml b/.github/workflows/wangapayfast-csharp.yml new file mode 100644 index 0000000..051ae4f --- /dev/null +++ b/.github/workflows/wangapayfast-csharp.yml @@ -0,0 +1,83 @@ +name: wangapayfast-csharp + +on: + pull_request: + types: [opened, synchronize, reopened] + paths: + - 'wangapayfast-csharp/**' + - '.github/workflows/wangapayfast-csharp.yml' + push: + branches: [main] + paths: + - 'wangapayfast-csharp/**' + - '.github/workflows/wangapayfast-csharp.yml' + workflow_dispatch: + +jobs: + format: + name: Format + runs-on: ubuntu-latest + if: github.event_name == 'pull_request' + steps: + - uses: actions/checkout@v4 + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '8.0.x' + - name: Restore + run: | + dotnet restore wangapayfast-csharp/WangaPayFast.CSharp.csproj + dotnet restore wangapayfast-csharp/tests/WangaPayFast.CSharp.Tests/WangaPayFast.CSharp.Tests.csproj + - name: Check formatting + run: dotnet format wangapayfast-csharp/WangaPayFast.CSharp.csproj --verify-no-changes --verbosity minimal + - name: Job summary + if: always() + run: | + echo "## Format" >> $GITHUB_STEP_SUMMARY + echo "**Status:** ${{ job.status }}" >> $GITHUB_STEP_SUMMARY + + build: + name: Build + runs-on: ubuntu-latest + needs: format + if: github.event_name == 'pull_request' || github.event_name == 'push' + steps: + - uses: actions/checkout@v4 + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '8.0.x' + - name: Restore + run: | + dotnet restore wangapayfast-csharp/WangaPayFast.CSharp.csproj + dotnet restore wangapayfast-csharp/tests/WangaPayFast.CSharp.Tests/WangaPayFast.CSharp.Tests.csproj + - name: Build + run: dotnet build wangapayfast-csharp/WangaPayFast.CSharp.csproj -c Release --no-restore + - name: Job summary + if: always() + run: | + echo "## Build" >> $GITHUB_STEP_SUMMARY + echo "**Status:** ${{ job.status }}" >> $GITHUB_STEP_SUMMARY + + test: + name: Test + runs-on: ubuntu-latest + needs: build + if: github.event_name == 'pull_request' || github.event_name == 'push' + steps: + - uses: actions/checkout@v4 + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '8.0.x' + - name: Restore + run: | + dotnet restore wangapayfast-csharp/WangaPayFast.CSharp.csproj + dotnet restore wangapayfast-csharp/tests/WangaPayFast.CSharp.Tests/WangaPayFast.CSharp.Tests.csproj + - name: Test + run: dotnet test wangapayfast-csharp/tests/WangaPayFast.CSharp.Tests/WangaPayFast.CSharp.Tests.csproj -c Release --no-build + - name: Job summary + if: always() + run: | + echo "## Test" >> $GITHUB_STEP_SUMMARY + echo "**Status:** ${{ job.status }}" >> $GITHUB_STEP_SUMMARY diff --git a/.github/workflows/wangapayfast-rs.yml b/.github/workflows/wangapayfast-rs.yml index 3c8df8c..1e15b9c 100644 --- a/.github/workflows/wangapayfast-rs.yml +++ b/.github/workflows/wangapayfast-rs.yml @@ -132,10 +132,72 @@ jobs: echo "## Check" >> $GITHUB_STEP_SUMMARY echo "**Status:** ${{ job.status }}" >> $GITHUB_STEP_SUMMARY + feature-matrix: + name: Feature Matrix (${{ matrix.feature_name }}) + runs-on: ubuntu-latest + needs: check + if: github.event_name == 'pull_request' + strategy: + fail-fast: false + matrix: + include: + - feature_name: default + feature_args: "" + - feature_name: http + feature_args: "--features http" + - feature_name: api + feature_args: "--features api" + - feature_name: onsite + feature_args: "--features onsite" + - feature_name: rest-api + feature_args: "--features rest-api" + - feature_name: graphql-api + feature_args: "--features graphql-api" + - feature_name: full + feature_args: "--features full" + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Cache Cargo + uses: actions/cache@v4 + with: + path: | + ~/.cargo/bin/ + ~/.cargo/registry/index/ + ~/.cargo/registry/cache/ + ~/.cargo/git/db/ + wangapayfast-rs/target/ + key: ${{ runner.os }}-cargo-test-${{ hashFiles('wangapayfast-rs/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-cargo-test- + + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + with: + toolchain: stable + + - name: Cargo check (matrix) + working-directory: wangapayfast-rs + run: | + if [ -n "${{ matrix.feature_args }}" ]; then + cargo check ${{ matrix.feature_args }} + else + cargo check + fi + + - name: Job summary + if: always() + run: | + echo "## Feature Matrix" >> $GITHUB_STEP_SUMMARY + echo "**Feature Set:** ${{ matrix.feature_name }}" >> $GITHUB_STEP_SUMMARY + echo "**Status:** ${{ job.status }}" >> $GITHUB_STEP_SUMMARY + test: name: Test runs-on: ubuntu-latest - needs: check + needs: feature-matrix if: github.event_name == 'pull_request' steps: - uses: actions/checkout@v4 @@ -391,6 +453,16 @@ jobs: with: toolchain: stable + - name: Validate publish token + env: + CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} + run: | + if [ -z "$CARGO_REGISTRY_TOKEN" ]; then + echo "Error: CARGO_REGISTRY_TOKEN is empty or not configured in repository secrets." + echo "Set Settings -> Secrets and variables -> Actions -> CARGO_REGISTRY_TOKEN" + exit 1 + fi + - name: Publish to crates.io working-directory: wangapayfast-rs env: @@ -443,7 +515,7 @@ jobs: name: Monitor Failed Jobs runs-on: ubuntu-latest if: always() && failure() - needs: [format, lint, check, test, version-bump, build, get-version, publish, release] + needs: [format, lint, check, feature-matrix, test, version-bump, build, get-version, publish, release] permissions: contents: read issues: write diff --git a/wangamail-csharp/GraphMailClient.cs b/wangamail-csharp/GraphMailClient.cs new file mode 100644 index 0000000..14c33e4 --- /dev/null +++ b/wangamail-csharp/GraphMailClient.cs @@ -0,0 +1,48 @@ +using System.Net; +using System.Net.Http.Json; + +namespace WangaMail.CSharp; + +public sealed class GraphMailClient +{ + private readonly HttpClient _httpClient; + private readonly TokenProvider _tokenProvider; + private readonly string _graphBase; + + internal GraphMailClient(HttpClient httpClient, TokenProvider tokenProvider, string graphBase) + { + _httpClient = httpClient; + _tokenProvider = tokenProvider; + _graphBase = graphBase.TrimEnd('/'); + } + + public static GraphMailClientBuilder Builder() => new(); + + public async Task SendMailAsync( + string fromUser, + SendMailRequest request, + CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(fromUser); + ArgumentNullException.ThrowIfNull(request); + + var token = await _tokenProvider.GetTokenAsync(cancellationToken).ConfigureAwait(false); + var escapedFromUser = Uri.EscapeDataString(fromUser); + var url = $"{_graphBase}/users/{escapedFromUser}/sendMail"; + + using var message = new HttpRequestMessage(HttpMethod.Post, url) + { + Content = JsonContent.Create(request) + }; + message.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token); + + using var response = await _httpClient.SendAsync(message, cancellationToken).ConfigureAwait(false); + if (response.StatusCode == HttpStatusCode.Accepted) + { + return; + } + + var body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); + throw new WangaMailException($"Graph API error: sendMail failed: {(int)response.StatusCode} {body}"); + } +} diff --git a/wangamail-csharp/GraphMailClientBuilder.cs b/wangamail-csharp/GraphMailClientBuilder.cs new file mode 100644 index 0000000..e39b728 --- /dev/null +++ b/wangamail-csharp/GraphMailClientBuilder.cs @@ -0,0 +1,84 @@ +namespace WangaMail.CSharp; + +public sealed class GraphMailClientBuilder +{ + private const string DefaultScope = "https://graph.microsoft.com/.default"; + private const string DefaultGraphBase = "https://graph.microsoft.com/v1.0"; + + private string? _tenantId; + private string? _clientId; + private string? _clientSecret; + private string? _tokenUrl; + private string? _graphBase; + private string? _scope; + private HttpClient? _httpClient; + + public GraphMailClientBuilder TenantId(string tenantId) + { + _tenantId = tenantId; + return this; + } + + public GraphMailClientBuilder ClientId(string clientId) + { + _clientId = clientId; + return this; + } + + public GraphMailClientBuilder ClientSecret(string clientSecret) + { + _clientSecret = clientSecret; + return this; + } + + public GraphMailClientBuilder TokenUrl(string tokenUrl) + { + _tokenUrl = tokenUrl; + return this; + } + + public GraphMailClientBuilder GraphBase(string graphBase) + { + _graphBase = graphBase; + return this; + } + + public GraphMailClientBuilder Scope(string scope) + { + _scope = scope; + return this; + } + + public GraphMailClientBuilder HttpClient(HttpClient httpClient) + { + _httpClient = httpClient; + return this; + } + + public GraphMailClient Build() + { + if (string.IsNullOrWhiteSpace(_tenantId)) + { + throw new WangaMailException("Invalid configuration: tenant_id is required."); + } + + if (string.IsNullOrWhiteSpace(_clientId)) + { + throw new WangaMailException("Invalid configuration: client_id is required."); + } + + if (string.IsNullOrWhiteSpace(_clientSecret)) + { + throw new WangaMailException("Invalid configuration: client_secret is required."); + } + + var tokenUrl = _tokenUrl ?? $"https://login.microsoftonline.com/{_tenantId}/oauth2/v2.0/token"; + var graphBase = _graphBase ?? DefaultGraphBase; + var scope = _scope ?? DefaultScope; + + var httpClient = _httpClient ?? new HttpClient(); + var tokenProvider = new TokenProvider(httpClient, _clientId, _clientSecret, tokenUrl, scope); + + return new GraphMailClient(httpClient, tokenProvider, graphBase); + } +} diff --git a/wangamail-csharp/GraphModels.cs b/wangamail-csharp/GraphModels.cs new file mode 100644 index 0000000..a491205 --- /dev/null +++ b/wangamail-csharp/GraphModels.cs @@ -0,0 +1,95 @@ +using System.Text.Json.Serialization; + +namespace WangaMail.CSharp; + +public enum BodyType +{ + Text, + [JsonStringEnumMemberName("HTML")] + Html +} + +public sealed class EmailAddress +{ + [JsonPropertyName("address")] + public string Address { get; set; } = string.Empty; + + [JsonPropertyName("name")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Name { get; set; } + + public static EmailAddress Create(string address, string? name = null) + { + return new EmailAddress { Address = address, Name = name }; + } +} + +public sealed class Recipient +{ + [JsonPropertyName("emailAddress")] + public EmailAddress EmailAddress { get; set; } = new(); + + public static Recipient Create(string address, string? name = null) + { + return new Recipient { EmailAddress = EmailAddress.Create(address, name) }; + } +} + +public sealed class MessageBody +{ + [JsonPropertyName("contentType")] + [JsonConverter(typeof(JsonStringEnumConverter))] + public BodyType ContentType { get; set; } = BodyType.Text; + + [JsonPropertyName("content")] + public string Content { get; set; } = string.Empty; +} + +public sealed class FileAttachment +{ + [JsonPropertyName("@odata.type")] + public string ODataType { get; set; } = "#microsoft.graph.fileAttachment"; + + [JsonPropertyName("name")] + public string Name { get; set; } = string.Empty; + + [JsonPropertyName("contentType")] + public string ContentType { get; set; } = "application/octet-stream"; + + [JsonPropertyName("contentBytes")] + public string ContentBytes { get; set; } = string.Empty; +} + +public sealed class Message +{ + [JsonPropertyName("subject")] + public string Subject { get; set; } = string.Empty; + + [JsonPropertyName("body")] + public MessageBody Body { get; set; } = new(); + + [JsonPropertyName("toRecipients")] + public List ToRecipients { get; set; } = []; + + [JsonPropertyName("ccRecipients")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public List? CcRecipients { get; set; } + + [JsonPropertyName("bccRecipients")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public List? BccRecipients { get; set; } + + [JsonPropertyName("attachments")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public List? Attachments { get; set; } +} + +public sealed class SendMailRequest +{ + [JsonPropertyName("message")] + public Message Message { get; set; } = new(); + + [JsonPropertyName("saveToSentItems")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public bool? SaveToSentItems { get; set; } = true; +} diff --git a/wangamail-csharp/README.md b/wangamail-csharp/README.md new file mode 100644 index 0000000..293ebf1 --- /dev/null +++ b/wangamail-csharp/README.md @@ -0,0 +1,60 @@ +# wangamail-csharp + +Send email on behalf of a Microsoft tenant using Microsoft Graph API and app registration credentials (OAuth2 client credentials flow). + +## Features + +- Typed client API for `POST /users/{id}/sendMail` +- OAuth2 token acquisition and in-memory token caching +- Strongly typed mail payload models (subject/body/recipients/attachments) +- Sovereign cloud support via `TokenUrl(...)` and `GraphBase(...)` + +## Install + +Add the package to your project: + +```bash +dotnet add package wangamail-csharp +``` + +## Usage + +```csharp +using WangaMail.CSharp; + +var client = GraphMailClient.Builder() + .TenantId(Environment.GetEnvironmentVariable("AZURE_TENANT_ID")!) + .ClientId(Environment.GetEnvironmentVariable("AZURE_CLIENT_ID")!) + .ClientSecret(Environment.GetEnvironmentVariable("AZURE_CLIENT_SECRET")!) + .Build(); + +var request = new SendMailRequest +{ + Message = new Message + { + Subject = "Hello from Graph", + Body = new MessageBody + { + ContentType = BodyType.Text, + Content = "This email was sent via Microsoft Graph." + }, + ToRecipients = + [ + Recipient.Create("recipient@example.com") + ] + } +}; + +await client.SendMailAsync("user@yourtenant.onmicrosoft.com", request); +``` + +## Azure setup + +1. Register an application in Microsoft Entra ID. +2. Create a client secret. +3. Add Microsoft Graph application permission `Mail.Send`. +4. Grant admin consent. + +## License + +MIT diff --git a/wangamail-csharp/TokenProvider.cs b/wangamail-csharp/TokenProvider.cs new file mode 100644 index 0000000..bd4626f --- /dev/null +++ b/wangamail-csharp/TokenProvider.cs @@ -0,0 +1,92 @@ +using System.Net.Http.Json; +using System.Text.Json.Serialization; + +namespace WangaMail.CSharp; + +internal sealed class TokenProvider +{ + private const int RefreshBufferSeconds = 60; + private readonly HttpClient _httpClient; + private readonly string _clientId; + private readonly string _clientSecret; + private readonly string _tokenUrl; + private readonly string _scope; + private readonly SemaphoreSlim _mutex = new(1, 1); + private TokenCache? _cached; + + public TokenProvider( + HttpClient httpClient, + string clientId, + string clientSecret, + string tokenUrl, + string scope) + { + _httpClient = httpClient; + _clientId = clientId; + _clientSecret = clientSecret; + _tokenUrl = tokenUrl; + _scope = scope; + } + + public async Task GetTokenAsync(CancellationToken cancellationToken = default) + { + if (_cached is not null && _cached.ExpiresAt > DateTimeOffset.UtcNow.AddSeconds(RefreshBufferSeconds)) + { + return _cached.Token; + } + + await _mutex.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + if (_cached is not null && _cached.ExpiresAt > DateTimeOffset.UtcNow.AddSeconds(RefreshBufferSeconds)) + { + return _cached.Token; + } + + var form = new Dictionary + { + ["grant_type"] = "client_credentials", + ["client_id"] = _clientId, + ["client_secret"] = _clientSecret, + ["scope"] = _scope + }; + + using var response = await _httpClient.PostAsync( + _tokenUrl, + new FormUrlEncodedContent(form), + cancellationToken).ConfigureAwait(false); + + var body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); + if (!response.IsSuccessStatusCode) + { + throw new WangaMailException($"Authentication failed: HTTP {(int)response.StatusCode} {body}"); + } + + var payload = await response.Content.ReadFromJsonAsync(cancellationToken: cancellationToken) + .ConfigureAwait(false); + if (payload is null || string.IsNullOrWhiteSpace(payload.AccessToken)) + { + throw new WangaMailException("Authentication failed: Invalid token response payload."); + } + + var expiresAt = DateTimeOffset.UtcNow.AddSeconds(Math.Max(payload.ExpiresIn - RefreshBufferSeconds, 1)); + _cached = new TokenCache(payload.AccessToken, expiresAt); + return payload.AccessToken; + } + finally + { + _mutex.Release(); + } + } + + private sealed record TokenCache(string Token, DateTimeOffset ExpiresAt); + + private sealed class TokenResponse + { + [JsonPropertyName("access_token")] + public string AccessToken { get; set; } = string.Empty; + + [JsonPropertyName("expires_in")] + public int ExpiresIn { get; set; } + } +} diff --git a/wangamail-csharp/WangaMail.CSharp.csproj b/wangamail-csharp/WangaMail.CSharp.csproj new file mode 100644 index 0000000..f98e643 --- /dev/null +++ b/wangamail-csharp/WangaMail.CSharp.csproj @@ -0,0 +1,20 @@ + + + net8.0 + enable + enable + true + wangamail-csharp + 0.1.0 + 1nga Solutions + Send email via Microsoft Graph API using app registration (client credentials). + https://github.com/1ngaOS/libs/tree/main/wangamail-csharp + README.md + wangamail;graph;microsoft;email;oauth2 + MIT + + + + + + diff --git a/wangamail-csharp/WangaMailException.cs b/wangamail-csharp/WangaMailException.cs new file mode 100644 index 0000000..577d586 --- /dev/null +++ b/wangamail-csharp/WangaMailException.cs @@ -0,0 +1,12 @@ +namespace WangaMail.CSharp; + +public sealed class WangaMailException : Exception +{ + public WangaMailException(string message) : base(message) + { + } + + public WangaMailException(string message, Exception innerException) : base(message, innerException) + { + } +} diff --git a/wangamail-csharp/tests/WangaMail.CSharp.Tests/GraphModelsTests.cs b/wangamail-csharp/tests/WangaMail.CSharp.Tests/GraphModelsTests.cs new file mode 100644 index 0000000..6ffea84 --- /dev/null +++ b/wangamail-csharp/tests/WangaMail.CSharp.Tests/GraphModelsTests.cs @@ -0,0 +1,39 @@ +using System.Text.Json; +using WangaMail.CSharp; + +namespace WangaMail.CSharp.Tests; + +public sealed class GraphModelsTests +{ + [Fact] + public void SerializeSendMailRequest_UsesGraphFieldNames() + { + var request = new SendMailRequest + { + Message = new Message + { + Subject = "Test", + Body = new MessageBody + { + ContentType = BodyType.Text, + Content = "Hello" + }, + ToRecipients = [Recipient.Create("to@example.com")] + } + }; + + var json = JsonSerializer.Serialize(request); + + Assert.Contains("\"saveToSentItems\":true", json); + Assert.Contains("\"toRecipients\"", json); + Assert.Contains("\"emailAddress\"", json); + Assert.Contains("\"contentType\":\"Text\"", json); + } + + [Fact] + public void Builder_MissingRequiredFields_Throws() + { + var ex = Assert.Throws(() => GraphMailClient.Builder().Build()); + Assert.Contains("tenant_id is required", ex.Message); + } +} diff --git a/wangamail-csharp/tests/WangaMail.CSharp.Tests/WangaMail.CSharp.Tests.csproj b/wangamail-csharp/tests/WangaMail.CSharp.Tests/WangaMail.CSharp.Tests.csproj new file mode 100644 index 0000000..a5a7140 --- /dev/null +++ b/wangamail-csharp/tests/WangaMail.CSharp.Tests/WangaMail.CSharp.Tests.csproj @@ -0,0 +1,19 @@ + + + net8.0 + enable + enable + false + + + + + + + + + + + + + diff --git a/wangapayfast-csharp/PayFastClient.cs b/wangapayfast-csharp/PayFastClient.cs new file mode 100644 index 0000000..a5a8671 --- /dev/null +++ b/wangapayfast-csharp/PayFastClient.cs @@ -0,0 +1,201 @@ +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; + +namespace WangaPayFast.CSharp; + +public sealed class PayFastClient +{ + private readonly PayFastConfig _config; + private readonly PayFastEnvironment _environment; + + internal PayFastClient(PayFastConfig config, PayFastEnvironment environment) + { + _config = config; + _environment = environment; + } + + public static PayFastClientBuilder Builder() => new(); + + public CheckoutResponse BuildOnceOffCheckout(OnceOffPaymentRequest request) + { + ArgumentNullException.ThrowIfNull(request); + ValidateOnceOff(request); + + var parameters = BuildParamsFromOnceOff(request); + AddSignature(parameters); + return new CheckoutResponse { Url = ResolveProcessUrl(), Params = parameters }; + } + + public CheckoutResponse BuildSubscriptionCheckout(AdvancedPaymentRequest request) + { + ArgumentNullException.ThrowIfNull(request); + ValidateOnceOff(request.Base); + + var parameters = BuildParamsFromOnceOff(request.Base); + ApplySubscription(parameters, request.Subscription); + ApplySplit(parameters, request.Split); + AddSignature(parameters); + return new CheckoutResponse { Url = ResolveProcessUrl(), Params = parameters }; + } + + public CheckoutResponse BuildCustomCheckout(IDictionary parameters) + { + ArgumentNullException.ThrowIfNull(parameters); + + var normalized = new SortedDictionary(parameters, StringComparer.Ordinal); + normalized["merchant_id"] = _config.MerchantId!; + normalized["merchant_key"] = _config.MerchantKey!; + AddSignature(normalized); + return new CheckoutResponse { Url = ResolveProcessUrl(), Params = normalized }; + } + + private void ValidateOnceOff(OnceOffPaymentRequest request) + { + if (string.IsNullOrWhiteSpace(request.PaymentId)) + { + throw new WangaPayFastException("Validation error: payment_id is required."); + } + + if (string.IsNullOrWhiteSpace(request.Amount)) + { + throw new WangaPayFastException("Validation error: amount is required."); + } + + if (string.IsNullOrWhiteSpace(request.ItemName)) + { + throw new WangaPayFastException("Validation error: item_name is required."); + } + } + + private SortedDictionary BuildParamsFromOnceOff(OnceOffPaymentRequest request) + { + var parameters = new SortedDictionary(StringComparer.Ordinal) + { + ["merchant_id"] = _config.MerchantId!, + ["merchant_key"] = _config.MerchantKey!, + ["m_payment_id"] = request.PaymentId, + ["amount"] = request.Amount, + ["item_name"] = request.ItemName + }; + + AddIfSet(parameters, "item_description", request.ItemDescription); + AddIfSet(parameters, "currency", request.Currency ?? request.CurrencyCode); + AddIfSet(parameters, "name_first", request.NameFirst); + AddIfSet(parameters, "name_last", request.NameLast); + AddIfSet(parameters, "email_address", request.EmailAddress); + AddIfSet(parameters, "cell_number", request.CellNumber); + AddIfSet(parameters, "return_url", request.ReturnUrl); + AddIfSet(parameters, "cancel_url", request.CancelUrl); + AddIfSet(parameters, "notify_url", request.NotifyUrl); + AddIfSet(parameters, "notify_method", request.NotifyMethod); + AddIfSet(parameters, "fica_id", request.FicaId); + AddIfSet(parameters, "confirmation_address", request.ConfirmationAddress); + + if (request.EmailConfirmation.HasValue) + { + parameters["email_confirmation"] = request.EmailConfirmation.Value ? "1" : "0"; + } + + foreach (var (key, value) in request.Custom) + { + AddIfSet(parameters, key, value); + } + + return parameters; + } + + private static void ApplySubscription(SortedDictionary parameters, SubscriptionOptions options) + { + AddIfSet(parameters, "subscription_type", options.SubscriptionType); + AddIfSet(parameters, "billing_date", options.BillingDate); + AddIfSet(parameters, "recurring_amount", options.RecurringAmount); + AddIfSet(parameters, "frequency", options.Frequency); + AddIfSet(parameters, "cycles", options.Cycles); + + if (options.SubscriptionNotifyEmail.HasValue) + { + parameters["subscription_notify_email"] = options.SubscriptionNotifyEmail.Value ? "true" : "false"; + } + + if (options.SubscriptionNotifyWebhook.HasValue) + { + parameters["subscription_notify_webhook"] = options.SubscriptionNotifyWebhook.Value ? "true" : "false"; + } + + if (options.SubscriptionNotifyBuyer.HasValue) + { + parameters["subscription_notify_buyer"] = options.SubscriptionNotifyBuyer.Value ? "true" : "false"; + } + } + + private static void ApplySplit(SortedDictionary parameters, SplitPayment split) + { + AddIfSet(parameters, "custom_str3", split.PrimaryReceiver); + AddIfSet(parameters, "custom_str4", split.SecondaryReceiver); + AddIfSet(parameters, "custom_str5", split.SecondaryAmount); + + var setup = split.Setup; + if (string.IsNullOrWhiteSpace(setup) && split.SetupPayload is not null) + { + setup = JsonSerializer.Serialize(split.SetupPayload); + } + + AddIfSet(parameters, "setup", setup); + + foreach (var (key, value) in split.Custom) + { + AddIfSet(parameters, key, value); + } + } + + private void AddSignature(SortedDictionary parameters) + { + parameters["signature"] = GenerateCheckoutSignature(parameters, _config.Passphrase); + } + + private static void AddIfSet(IDictionary parameters, string key, string? value) + { + if (!string.IsNullOrWhiteSpace(value)) + { + parameters[key] = value; + } + } + + private string ResolveProcessUrl() + { + return _environment == PayFastEnvironment.Sandbox + ? "https://sandbox.payfast.co.za/eng/process" + : "https://www.payfast.co.za/eng/process"; + } + + internal static string GenerateCheckoutSignature( + IReadOnlyDictionary parameters, + string? passphrase = null) + { + var payload = BuildSignaturePayload(parameters, passphrase); + var bytes = Encoding.UTF8.GetBytes(payload); + var hash = MD5.HashData(bytes); + return Convert.ToHexStringLower(hash); + } + + internal static string BuildSignaturePayload( + IReadOnlyDictionary parameters, + string? passphrase = null) + { + var filtered = parameters + .Where(pair => !string.Equals(pair.Key, "signature", StringComparison.OrdinalIgnoreCase)) + .Where(pair => !string.IsNullOrWhiteSpace(pair.Value)) + .Where(pair => !string.Equals(pair.Key, "setup", StringComparison.Ordinal)) + .OrderBy(pair => pair.Key, StringComparer.Ordinal) + .Select(pair => $"{pair.Key}={Uri.EscapeDataString(pair.Value).Replace("%20", "+")}"); + + var payload = string.Join("&", filtered); + if (!string.IsNullOrWhiteSpace(passphrase)) + { + payload += $"&passphrase={Uri.EscapeDataString(passphrase).Replace("%20", "+")}"; + } + + return payload; + } +} diff --git a/wangapayfast-csharp/PayFastClientBuilder.cs b/wangapayfast-csharp/PayFastClientBuilder.cs new file mode 100644 index 0000000..dd738ee --- /dev/null +++ b/wangapayfast-csharp/PayFastClientBuilder.cs @@ -0,0 +1,46 @@ +namespace WangaPayFast.CSharp; + +public sealed class PayFastClientBuilder +{ + private readonly PayFastConfig _config = new(); + private PayFastEnvironment _environment = PayFastEnvironment.Live; + + public PayFastClientBuilder MerchantId(string merchantId) + { + _config.MerchantId = merchantId; + return this; + } + + public PayFastClientBuilder MerchantKey(string merchantKey) + { + _config.MerchantKey = merchantKey; + return this; + } + + public PayFastClientBuilder Passphrase(string passphrase) + { + _config.Passphrase = passphrase; + return this; + } + + public PayFastClientBuilder Environment(PayFastEnvironment environment) + { + _environment = environment; + return this; + } + + public PayFastClient Build() + { + if (string.IsNullOrWhiteSpace(_config.MerchantId)) + { + throw new WangaPayFastException("Invalid configuration: merchant_id is required."); + } + + if (string.IsNullOrWhiteSpace(_config.MerchantKey)) + { + throw new WangaPayFastException("Invalid configuration: merchant_key is required."); + } + + return new PayFastClient(_config, _environment); + } +} diff --git a/wangapayfast-csharp/PayFastModels.cs b/wangapayfast-csharp/PayFastModels.cs new file mode 100644 index 0000000..4c547a0 --- /dev/null +++ b/wangapayfast-csharp/PayFastModels.cs @@ -0,0 +1,101 @@ +using System.Text.Json.Serialization; + +namespace WangaPayFast.CSharp; + +public enum PayFastEnvironment +{ + Live, + Sandbox +} + +public sealed class PayFastConfig +{ + public string? MerchantId { get; set; } + public string? MerchantKey { get; set; } + public string? Passphrase { get; set; } +} + +public sealed class SplitPaymentSetup +{ + [JsonPropertyName("split_payment")] + public SplitPaymentRule SplitPayment { get; set; } = new(); +} + +public sealed class SplitPaymentRule +{ + [JsonPropertyName("merchant_id")] + public ulong MerchantId { get; set; } + + [JsonPropertyName("amount")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public ulong? Amount { get; set; } + + [JsonPropertyName("percentage")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public ulong? Percentage { get; set; } + + [JsonPropertyName("min")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public ulong? Min { get; set; } + + [JsonPropertyName("max")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public ulong? Max { get; set; } +} + +public sealed class OnceOffPaymentRequest +{ + public string PaymentId { get; set; } = string.Empty; + public string Amount { get; set; } = string.Empty; + public string ItemName { get; set; } = string.Empty; + public string? ItemDescription { get; set; } + public string? Currency { get; set; } + public string? CurrencyCode { get; set; } + public string? NameFirst { get; set; } + public string? NameLast { get; set; } + public string? EmailAddress { get; set; } + public string? CellNumber { get; set; } + public string? ReturnUrl { get; set; } + public string? CancelUrl { get; set; } + public string? NotifyUrl { get; set; } + public string? NotifyMethod { get; set; } + public string? FicaId { get; set; } + public bool? EmailConfirmation { get; set; } + public string? ConfirmationAddress { get; set; } + public Dictionary Custom { get; set; } = new(); +} + +public sealed class SubscriptionOptions +{ + public string? SubscriptionType { get; set; } + public string? BillingDate { get; set; } + public string? RecurringAmount { get; set; } + public string? Frequency { get; set; } + public string? Cycles { get; set; } + public bool? SubscriptionNotifyEmail { get; set; } + public bool? SubscriptionNotifyWebhook { get; set; } + public bool? SubscriptionNotifyBuyer { get; set; } +} + +public sealed class SplitPayment +{ + public string? PrimaryReceiver { get; set; } + public string? SecondaryReceiver { get; set; } + public string? SecondaryAmount { get; set; } + public string? Setup { get; set; } + public SplitPaymentSetup? SetupPayload { get; set; } + public Dictionary Custom { get; set; } = new(); +} + +public sealed class AdvancedPaymentRequest +{ + public OnceOffPaymentRequest Base { get; set; } = new(); + public SubscriptionOptions Subscription { get; set; } = new(); + public SplitPayment Split { get; set; } = new(); +} + +public sealed class CheckoutResponse +{ + public string Url { get; set; } = string.Empty; + public SortedDictionary Params { get; set; } = new(StringComparer.Ordinal); +} diff --git a/wangapayfast-csharp/README.md b/wangapayfast-csharp/README.md new file mode 100644 index 0000000..0106148 --- /dev/null +++ b/wangapayfast-csharp/README.md @@ -0,0 +1,47 @@ +# wangapayfast-csharp + +Helpers for building [PayFast](https://www.payfast.co.za/) checkout payloads and signatures in .NET services. + +## Features + +- Build once-off checkout payloads +- Build subscription checkout payloads +- Build custom checkout payloads from arbitrary parameters +- Generate PayFast-compatible checkout signatures +- Sandbox and live environment support + +## Install + +```bash +dotnet add package wangapayfast-csharp +``` + +## Usage + +```csharp +using WangaPayFast.CSharp; + +var client = PayFastClient.Builder() + .MerchantId(Environment.GetEnvironmentVariable("PAYFAST_MERCHANT_ID")!) + .MerchantKey(Environment.GetEnvironmentVariable("PAYFAST_MERCHANT_KEY")!) + .Passphrase(Environment.GetEnvironmentVariable("PAYFAST_PASSPHRASE")!) + .Environment(PayFastEnvironment.Sandbox) + .Build(); + +var checkout = client.BuildOnceOffCheckout(new OnceOffPaymentRequest +{ + PaymentId = "ORDER-1001", + Amount = "100.00", + ItemName = "Starter Plan", + ReturnUrl = "https://example.com/payments/success", + CancelUrl = "https://example.com/payments/cancel", + NotifyUrl = "https://example.com/payments/itn" +}); + +// checkout.Url => https://sandbox.payfast.co.za/eng/process +// checkout.Params => includes merchant fields + signature +``` + +## License + +MIT diff --git a/wangapayfast-csharp/WangaPayFast.CSharp.csproj b/wangapayfast-csharp/WangaPayFast.CSharp.csproj new file mode 100644 index 0000000..16c62ce --- /dev/null +++ b/wangapayfast-csharp/WangaPayFast.CSharp.csproj @@ -0,0 +1,20 @@ + + + net8.0 + enable + enable + true + wangapayfast-csharp + 0.1.0 + 1nga Solutions + Helpers for building PayFast checkout payloads and signatures for .NET services. + https://github.com/1ngaOS/libs/tree/main/wangapayfast-csharp + README.md + payfast;payments;checkout;itn;south-africa + MIT + + + + + + diff --git a/wangapayfast-csharp/WangaPayFastException.cs b/wangapayfast-csharp/WangaPayFastException.cs new file mode 100644 index 0000000..c3155f2 --- /dev/null +++ b/wangapayfast-csharp/WangaPayFastException.cs @@ -0,0 +1,12 @@ +namespace WangaPayFast.CSharp; + +public sealed class WangaPayFastException : Exception +{ + public WangaPayFastException(string message) : base(message) + { + } + + public WangaPayFastException(string message, Exception innerException) : base(message, innerException) + { + } +} diff --git a/wangapayfast-csharp/tests/WangaPayFast.CSharp.Tests/PayFastClientTests.cs b/wangapayfast-csharp/tests/WangaPayFast.CSharp.Tests/PayFastClientTests.cs new file mode 100644 index 0000000..75a993d --- /dev/null +++ b/wangapayfast-csharp/tests/WangaPayFast.CSharp.Tests/PayFastClientTests.cs @@ -0,0 +1,62 @@ +using WangaPayFast.CSharp; + +namespace WangaPayFast.CSharp.Tests; + +public sealed class PayFastClientTests +{ + [Fact] + public void Builder_MissingRequiredFields_Throws() + { + var ex = Assert.Throws(() => PayFastClient.Builder().Build()); + Assert.Contains("merchant_id is required", ex.Message); + } + + [Fact] + public void BuildOnceOffCheckout_AddsMerchantFieldsAndSignature() + { + var client = PayFastClient.Builder() + .MerchantId("10000100") + .MerchantKey("46f0cd694581a") + .Passphrase("abc123") + .Environment(PayFastEnvironment.Sandbox) + .Build(); + + var response = client.BuildOnceOffCheckout(new OnceOffPaymentRequest + { + PaymentId = "ORDER-1", + Amount = "100.00", + ItemName = "Subscription", + ReturnUrl = "https://example.com/success", + CancelUrl = "https://example.com/cancel", + NotifyUrl = "https://example.com/itn" + }); + + Assert.Equal("https://sandbox.payfast.co.za/eng/process", response.Url); + Assert.Equal("10000100", response.Params["merchant_id"]); + Assert.Equal("46f0cd694581a", response.Params["merchant_key"]); + Assert.True(response.Params.ContainsKey("signature")); + Assert.False(string.IsNullOrWhiteSpace(response.Params["signature"])); + } + + [Fact] + public void BuildSignaturePayload_ExcludesSetupAndSignatureFields() + { + var payload = PayFastClient.BuildSignaturePayload( + new Dictionary + { + ["merchant_id"] = "10000100", + ["merchant_key"] = "46f0cd694581a", + ["amount"] = "100.00", + ["item_name"] = "Hello World", + ["setup"] = "{\"split_payment\":{\"merchant_id\":1}}", + ["signature"] = "ignoreme" + }, + "pass" + ); + + Assert.DoesNotContain("setup=", payload, StringComparison.Ordinal); + Assert.DoesNotContain("signature=", payload, StringComparison.Ordinal); + Assert.Contains("item_name=Hello+World", payload, StringComparison.Ordinal); + Assert.EndsWith("passphrase=pass", payload, StringComparison.Ordinal); + } +} diff --git a/wangapayfast-csharp/tests/WangaPayFast.CSharp.Tests/WangaPayFast.CSharp.Tests.csproj b/wangapayfast-csharp/tests/WangaPayFast.CSharp.Tests/WangaPayFast.CSharp.Tests.csproj new file mode 100644 index 0000000..b727708 --- /dev/null +++ b/wangapayfast-csharp/tests/WangaPayFast.CSharp.Tests/WangaPayFast.CSharp.Tests.csproj @@ -0,0 +1,19 @@ + + + net8.0 + enable + enable + false + + + + + + + + + + + + + diff --git a/wangapayfast-rs/README.md b/wangapayfast-rs/README.md index 0c2d352..0c72e27 100644 --- a/wangapayfast-rs/README.md +++ b/wangapayfast-rs/README.md @@ -1,16 +1,17 @@ # wangapayfast-rs -Helpers for working with [PayFast](https://www.payfast.co.za/) ITN (Instant Transaction Notification) -messages in Rust services. +Helpers for working with [PayFast](https://www.payfast.co.za/) in Rust services: +ITN verification, checkout payload/signature building, onsite helpers, and +optional server-to-server API calls. -This crate focuses on: +This crate provides: - Parsing the `application/x-www-form-urlencoded` ITN body -- Regenerating the PayFast signature according to their documentation -- Verifying that an incoming ITN is authentic before you update your own records - -It intentionally does **not** make outbound HTTP requests – you can use it with -any HTTP framework. +- Regenerating and verifying PayFast signatures +- Building once-off and subscription checkout payloads +- Optional HTTP post-back ITN validation (`http` feature) +- Optional API client for recurring/transactions/refunds (`api` feature) +- Optional onsite payment-identifier helper (`onsite` feature) ## Usage @@ -21,6 +22,23 @@ Add to your `Cargo.toml`: wangapayfast-rs = "0.1" ``` +Enable optional capabilities with features: + +```toml +[dependencies] +wangapayfast-rs = { version = "0.1", features = ["http", "api"] } +``` + +## Feature Flags + +- `default`: Core ITN parsing + signature helpers + checkout payload builders. +- `http`: Adds HTTP post-back ITN validation against PayFast. +- `api`: Adds async PayFast API client (subscriptions, transactions, refunds). +- `onsite`: Adds onsite payment identifier generation helper. +- `rest-api`: Adds Axum REST routes for checkout/ITN helper endpoints. +- `graphql-api`: Adds GraphQL router for checkout operations. +- `full`: Enables all optional features. + Example (pseudo‑handler): ```rust diff --git a/wangapayfast-rs/src/error.rs b/wangapayfast-rs/src/error.rs index 777168d..ed3943e 100644 --- a/wangapayfast-rs/src/error.rs +++ b/wangapayfast-rs/src/error.rs @@ -20,17 +20,17 @@ pub enum Error { Validation(String), /// HTTP error while calling PayFast (API / post-back validation). - #[cfg(feature = "api")] + #[cfg(any(feature = "api", feature = "onsite"))] #[error("http error: {0}")] Http(#[from] reqwest::Error), /// JSON serialization/deserialization error. - #[cfg(feature = "api")] + #[cfg(any(feature = "api", feature = "onsite"))] #[error("json error: {0}")] Json(#[from] serde_json::Error), /// Non-2xx response from PayFast API. - #[cfg(feature = "api")] + #[cfg(any(feature = "api", feature = "onsite"))] #[error("api http error: status={status} body={body}")] ApiHttp { /// HTTP status code. diff --git a/wangapayfast-rs/src/lib.rs b/wangapayfast-rs/src/lib.rs index 029f5f4..e180b74 100644 --- a/wangapayfast-rs/src/lib.rs +++ b/wangapayfast-rs/src/lib.rs @@ -14,6 +14,8 @@ //! - Helper predicates for business notifications (paid / failed / cancelled) //! - Helpers to generate checkout signatures for custom integrations //! - Optional HTTP-based post-back verification to PayFast (`http` feature) +//! - Optional API client helpers for subscriptions/refunds/transactions (`api` feature) +//! - Optional onsite payment identifier generation (`onsite` feature) //! //! ## Quick start //! diff --git a/wangapayfast-rs/src/onsite.rs b/wangapayfast-rs/src/onsite.rs index 18665b9..cc6d02f 100644 --- a/wangapayfast-rs/src/onsite.rs +++ b/wangapayfast-rs/src/onsite.rs @@ -1,6 +1,9 @@ +#[cfg(feature = "onsite")] use serde::Deserialize; +#[cfg(feature = "onsite")] use crate::error::{Error, Result}; +#[cfg(feature = "onsite")] use crate::{generate_checkout_signature, CheckoutFieldOrder, CheckoutParams, PayFastConfig}; /// Onsite payments environment. @@ -13,6 +16,7 @@ pub enum OnsiteEnvironment { } impl OnsiteEnvironment { + #[cfg(feature = "onsite")] fn process_url(self) -> &'static str { match self { OnsiteEnvironment::Live => "https://www.payfast.co.za/onsite/process", @@ -28,6 +32,7 @@ impl OnsiteEnvironment { } } +#[cfg(feature = "onsite")] #[derive(Debug, Deserialize)] struct IdentifierResponse { uuid: String, From 1f5ba70b1efb005eefb34f71d0f70ed5ad4be830 Mon Sep 17 00:00:00 2001 From: Mitch Chimwemwe Chanza Date: Thu, 26 Mar 2026 15:14:00 +0200 Subject: [PATCH 02/10] feat(wangapayfast-csharp): implement ITN notification handling and HTTP helpers - Added `ItnNotification` class for parsing and verifying ITN payloads. - Introduced `PayFastHttpHelpers` for post-back validation and generating payment identifiers. - Enhanced `PayFastClient` with methods for ITN signature generation and verification. - Updated `PayFastModels` to include `ItnPaymentStatus` enum and `OnsiteProcessResponse` model. - Expanded README with ITN and HTTP helper usage examples. - Added unit tests for ITN signature verification and post-back validation. --- .github/workflows/wangapayfast-csharp.yml | 282 ++++++++++++++++++ wangapayfast-csharp/ItnNotification.cs | 76 +++++ wangapayfast-csharp/PayFastClient.cs | 41 ++- wangapayfast-csharp/PayFastHttpHelpers.cs | 100 +++++++ wangapayfast-csharp/PayFastModels.cs | 14 + wangapayfast-csharp/README.md | 31 +- .../PayFastClientTests.cs | 85 ++++++ 7 files changed, 627 insertions(+), 2 deletions(-) create mode 100644 wangapayfast-csharp/ItnNotification.cs create mode 100644 wangapayfast-csharp/PayFastHttpHelpers.cs diff --git a/.github/workflows/wangapayfast-csharp.yml b/.github/workflows/wangapayfast-csharp.yml index 051ae4f..f7532ee 100644 --- a/.github/workflows/wangapayfast-csharp.yml +++ b/.github/workflows/wangapayfast-csharp.yml @@ -81,3 +81,285 @@ jobs: run: | echo "## Test" >> $GITHUB_STEP_SUMMARY echo "**Status:** ${{ job.status }}" >> $GITHUB_STEP_SUMMARY + + version-bump: + name: Bump Version + runs-on: ubuntu-latest + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + outputs: + version: ${{ steps.bump.outputs.new_version }} + permissions: + contents: write + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Get current version + id: current + run: | + python3 - <<'PY' + import re + from pathlib import Path + import os + text = Path("wangapayfast-csharp/WangaPayFast.CSharp.csproj").read_text(encoding="utf-8") + m = re.search(r"(\d+\.\d+\.\d+)", text) + if not m: + raise SystemExit("Error: Failed to extract Version from WangaPayFast.CSharp.csproj") + version = m.group(1) + with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as f: + f.write(f"version={version}\n") + print(f"Current version: {version}") + PY + + - name: Bump patch version + id: bump + run: | + V="${{ steps.current.outputs.version }}" + IFS='.' read -r MAJOR MINOR PATCH <<< "$V" + PATCH=$((PATCH + 1)) + NEW="${MAJOR}.${MINOR}.${PATCH}" + echo "new_version=$NEW" >> $GITHUB_OUTPUT + python3 - <\d+\.\d+\.\d+", f"{NEW}", text, count=1) + if n != 1: + raise SystemExit("Error: Failed to update Version in WangaPayFast.CSharp.csproj") + path.write_text(out, encoding="utf-8") + print(f"New version: {NEW}") + PY + + - name: Configure Git + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + - name: Commit and push version + run: | + git add wangapayfast-csharp/WangaPayFast.CSharp.csproj + if git diff --staged --quiet; then + echo "No version change to commit" + else + git commit -m "chore(wangapayfast-csharp): bump version to ${{ steps.bump.outputs.new_version }} [skip ci]" + git fetch origin main || true + git pull origin main --rebase || git pull origin main --no-rebase || true + git push origin main || git push origin main --force-with-lease + fi + + - name: Job summary + if: always() + run: | + echo "## Bump Version" >> $GITHUB_STEP_SUMMARY + echo "**New version:** ${{ steps.bump.outputs.new_version }}" >> $GITHUB_STEP_SUMMARY + + get-version: + name: Get Version + runs-on: ubuntu-latest + needs: [version-bump] + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + outputs: + version: ${{ steps.version.outputs.version }} + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Pull latest + run: git pull origin main || true + + - name: Get version + id: version + run: | + V=$(python3 - <<'PY' + import re + from pathlib import Path + text = Path("wangapayfast-csharp/WangaPayFast.CSharp.csproj").read_text(encoding="utf-8") + m = re.search(r"(\d+\.\d+\.\d+)", text) + print(m.group(1) if m else "") + PY + ) + if [ -z "$V" ]; then + echo "Error: Failed to extract version from csproj" + exit 1 + fi + echo "version=$V" >> $GITHUB_OUTPUT + echo "Version: $V" + + - name: Job summary + if: always() + run: | + echo "## Get Version" >> $GITHUB_STEP_SUMMARY + echo "**Version:** ${{ steps.version.outputs.version }}" >> $GITHUB_STEP_SUMMARY + + publish: + name: Publish to NuGet + runs-on: ubuntu-latest + needs: [get-version] + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + permissions: + contents: read + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Pull latest + run: git pull origin main || true + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '8.0.x' + + - name: Validate NuGet key + env: + NUGET_API_KEY: ${{ secrets.NUGET_API_KEY }} + run: | + if [ -z "$NUGET_API_KEY" ]; then + echo "Error: NUGET_API_KEY is empty or not configured in repository secrets." + exit 1 + fi + + - name: Pack + run: dotnet pack wangapayfast-csharp/WangaPayFast.CSharp.csproj -c Release -o ./artifacts + + - name: Publish package + env: + NUGET_API_KEY: ${{ secrets.NUGET_API_KEY }} + run: | + dotnet nuget push "./artifacts/*.nupkg" \ + --api-key "$NUGET_API_KEY" \ + --source "https://api.nuget.org/v3/index.json" \ + --skip-duplicate + + - name: Job summary + if: always() + run: | + echo "## Publish" >> $GITHUB_STEP_SUMMARY + echo "**Status:** ${{ job.status }}" >> $GITHUB_STEP_SUMMARY + echo "**Version:** ${{ needs.get-version.outputs.version }}" >> $GITHUB_STEP_SUMMARY + + release: + name: Create Release + runs-on: ubuntu-latest + needs: [get-version, publish] + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + permissions: + contents: write + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Create tag and release + uses: softprops/action-gh-release@v2 + with: + tag_name: wangapayfast-csharp-v${{ needs.get-version.outputs.version }} + name: wangapayfast-csharp v${{ needs.get-version.outputs.version }} + body: | + ## wangapayfast-csharp ${{ needs.get-version.outputs.version }} + PayFast helpers for .NET services. + - [NuGet](https://www.nuget.org/packages/wangapayfast-csharp) + draft: false + prerelease: false + + - name: Job summary + if: always() + run: | + echo "## Release" >> $GITHUB_STEP_SUMMARY + echo "**Status:** ${{ job.status }}" >> $GITHUB_STEP_SUMMARY + echo "**Tag:** wangapayfast-csharp-v${{ needs.get-version.outputs.version }}" >> $GITHUB_STEP_SUMMARY + + monitor-failures: + name: Monitor Failed Jobs + runs-on: ubuntu-latest + if: always() && failure() + needs: [format, build, test, version-bump, get-version, publish, release] + permissions: + contents: read + issues: write + actions: read + steps: + - uses: actions/checkout@v4 + + - name: Create failure issue + uses: actions/github-script@v7 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const { data: run } = await github.rest.actions.getWorkflowRun({ + owner: context.repo.owner, + repo: context.repo.repo, + run_id: context.runId + }); + + const { data: jobsData } = await github.rest.actions.listJobsForWorkflowRun({ + owner: context.repo.owner, + repo: context.repo.repo, + run_id: context.runId + }); + + const failedJobs = jobsData.jobs.filter(job => + job.conclusion === 'failure' || + job.conclusion === 'cancelled' || + job.conclusion === 'timed_out' + ); + + if (failedJobs.length === 0) { + console.log('No failed jobs found'); + return; + } + + const branch = context.ref.replace('refs/heads/', ''); + const title = `🚨 CI/CD Failure: wangapayfast-csharp - ${branch} branch`; + + let body = `## CI/CD Pipeline Failure\n\n`; + body += `**Workflow:** [wangapayfast-csharp](${run.html_url})\n`; + body += `**Branch:** \`${branch}\`\n`; + body += `**Commit:** [${context.sha.substring(0, 7)}](${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/commit/${context.sha})\n`; + body += `**Triggered by:** @${context.actor}\n\n`; + body += `### Failed Jobs\n\n`; + for (const job of failedJobs) { + body += `- ❌ [${job.name}](${job.html_url}) (${job.conclusion})\n`; + } + body += `\n---\n*Automatically created by workflow monitor.*\n`; + + const { data: issues } = await github.rest.issues.listForRepo({ + owner: context.repo.owner, + repo: context.repo.repo, + state: 'open', + labels: 'ci-failure,wangapayfast-csharp' + }); + + const existingIssue = issues.find(issue => + issue.title === title || issue.title.includes(`wangapayfast-csharp - ${branch}`) + ); + + if (existingIssue) { + await github.rest.issues.update({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: existingIssue.number, + body, + state: 'open' + }); + } else { + await github.rest.issues.create({ + owner: context.repo.owner, + repo: context.repo.repo, + title, + body, + labels: ['ci-failure', 'wangapayfast-csharp', 'bug', 'priority-high'] + }); + } + + - name: Job summary + if: always() + run: | + echo "## Monitor Failed Jobs" >> $GITHUB_STEP_SUMMARY + echo "**Status:** ${{ job.status }}" >> $GITHUB_STEP_SUMMARY diff --git a/wangapayfast-csharp/ItnNotification.cs b/wangapayfast-csharp/ItnNotification.cs new file mode 100644 index 0000000..074a98e --- /dev/null +++ b/wangapayfast-csharp/ItnNotification.cs @@ -0,0 +1,76 @@ +using System.Collections.ObjectModel; + +namespace WangaPayFast.CSharp; + +public sealed class ItnNotification +{ + public IReadOnlyDictionary Raw { get; } + + private ItnNotification(SortedDictionary raw) + { + Raw = new ReadOnlyDictionary(raw); + } + + public static ItnNotification FromBody(byte[] body) + { + ArgumentNullException.ThrowIfNull(body); + return FromBody(System.Text.Encoding.UTF8.GetString(body)); + } + + public static ItnNotification FromBody(string body) + { + ArgumentException.ThrowIfNullOrWhiteSpace(body); + + var parsed = new SortedDictionary(StringComparer.Ordinal); + var pairs = body.Split('&', StringSplitOptions.RemoveEmptyEntries); + foreach (var pair in pairs) + { + var index = pair.IndexOf('='); + if (index < 0) + { + continue; + } + + var key = UrlDecode(pair[..index]); + var value = UrlDecode(pair[(index + 1)..]); + if (!string.IsNullOrWhiteSpace(key)) + { + parsed[key] = value; + } + } + + return new ItnNotification(parsed); + } + + public string? Signature => + Raw.TryGetValue("signature", out var value) ? value : null; + + public string? PaymentStatusRaw => + Raw.TryGetValue("payment_status", out var value) ? value : null; + + public ItnPaymentStatus PaymentStatus() + { + return PaymentStatusRaw?.ToUpperInvariant() switch + { + "COMPLETE" => ItnPaymentStatus.Complete, + "FAILED" => ItnPaymentStatus.Failed, + "CANCELLED" => ItnPaymentStatus.Cancelled, + _ => ItnPaymentStatus.Unknown + }; + } + + public bool IsGrossAmount(string expectedAmount) + { + if (Raw.TryGetValue("amount_gross", out var amount)) + { + return string.Equals(amount, expectedAmount, StringComparison.Ordinal); + } + + return false; + } + + private static string UrlDecode(string value) + { + return Uri.UnescapeDataString(value.Replace("+", " ", StringComparison.Ordinal)); + } +} diff --git a/wangapayfast-csharp/PayFastClient.cs b/wangapayfast-csharp/PayFastClient.cs index a5a8671..f34bb45 100644 --- a/wangapayfast-csharp/PayFastClient.cs +++ b/wangapayfast-csharp/PayFastClient.cs @@ -182,11 +182,50 @@ internal static string GenerateCheckoutSignature( internal static string BuildSignaturePayload( IReadOnlyDictionary parameters, string? passphrase = null) + { + return BuildSignaturePayload(parameters, passphrase, excludeSetupField: true); + } + + internal static string BuildItnSignaturePayload( + IReadOnlyDictionary parameters, + string? passphrase = null) + { + return BuildSignaturePayload(parameters, passphrase, excludeSetupField: false); + } + + public static string GenerateItnSignature( + IReadOnlyDictionary parameters, + string? passphrase = null) + { + var payload = BuildItnSignaturePayload(parameters, passphrase); + var bytes = Encoding.UTF8.GetBytes(payload); + var hash = MD5.HashData(bytes); + return Convert.ToHexStringLower(hash); + } + + public static bool VerifyItnSignature( + ItnNotification itn, + string? passphrase = null) + { + ArgumentNullException.ThrowIfNull(itn); + if (string.IsNullOrWhiteSpace(itn.Signature)) + { + return false; + } + + var expected = GenerateItnSignature(itn.Raw, passphrase); + return string.Equals(expected, itn.Signature, StringComparison.OrdinalIgnoreCase); + } + + private static string BuildSignaturePayload( + IReadOnlyDictionary parameters, + string? passphrase, + bool excludeSetupField) { var filtered = parameters .Where(pair => !string.Equals(pair.Key, "signature", StringComparison.OrdinalIgnoreCase)) .Where(pair => !string.IsNullOrWhiteSpace(pair.Value)) - .Where(pair => !string.Equals(pair.Key, "setup", StringComparison.Ordinal)) + .Where(pair => !excludeSetupField || !string.Equals(pair.Key, "setup", StringComparison.Ordinal)) .OrderBy(pair => pair.Key, StringComparer.Ordinal) .Select(pair => $"{pair.Key}={Uri.EscapeDataString(pair.Value).Replace("%20", "+")}"); diff --git a/wangapayfast-csharp/PayFastHttpHelpers.cs b/wangapayfast-csharp/PayFastHttpHelpers.cs new file mode 100644 index 0000000..b274dc8 --- /dev/null +++ b/wangapayfast-csharp/PayFastHttpHelpers.cs @@ -0,0 +1,100 @@ +using System.Net.Http.Json; +using System.Text; + +namespace WangaPayFast.CSharp; + +public static class PayFastHttpHelpers +{ + public static async Task PostBackValidateItnAsync( + HttpClient httpClient, + PayFastEnvironment environment, + string rawItnBody, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(httpClient); + ArgumentException.ThrowIfNullOrWhiteSpace(rawItnBody); + + var url = environment == PayFastEnvironment.Sandbox + ? "https://sandbox.payfast.co.za/eng/query/validate" + : "https://www.payfast.co.za/eng/query/validate"; + + using var content = new StringContent(rawItnBody, Encoding.UTF8, "application/x-www-form-urlencoded"); + using var response = await httpClient.PostAsync(url, content, cancellationToken).ConfigureAwait(false); + var body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); + + if (!response.IsSuccessStatusCode) + { + throw new WangaPayFastException($"PayFast post-back validation failed: {(int)response.StatusCode} {body}"); + } + + return string.Equals(body.Trim(), "VALID", StringComparison.OrdinalIgnoreCase); + } + + public static async Task GeneratePaymentIdentifierAsync( + HttpClient httpClient, + PayFastConfig config, + PayFastEnvironment environment, + IDictionary checkoutParams, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(httpClient); + ArgumentNullException.ThrowIfNull(config); + ArgumentNullException.ThrowIfNull(checkoutParams); + + if (string.IsNullOrWhiteSpace(config.MerchantId)) + { + throw new WangaPayFastException("Invalid configuration: merchant_id is required."); + } + + if (string.IsNullOrWhiteSpace(config.MerchantKey)) + { + throw new WangaPayFastException("Invalid configuration: merchant_key is required."); + } + + var normalized = new SortedDictionary(checkoutParams, StringComparer.Ordinal) + { + ["merchant_id"] = config.MerchantId, + ["merchant_key"] = config.MerchantKey + }; + normalized["signature"] = PayFastClient.GenerateCheckoutSignature(normalized, config.Passphrase); + + var url = environment == PayFastEnvironment.Sandbox + ? "https://sandbox.payfast.co.za/onsite/process" + : "https://www.payfast.co.za/onsite/process"; + + using var response = await httpClient.PostAsync(url, new FormUrlEncodedContent(normalized), cancellationToken) + .ConfigureAwait(false); + var body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); + + if (!response.IsSuccessStatusCode) + { + throw new WangaPayFastException($"PayFast onsite process failed: {(int)response.StatusCode} {body}"); + } + + var parsed = await response.Content.ReadFromJsonAsync(cancellationToken: cancellationToken) + .ConfigureAwait(false); + if (parsed is null || string.IsNullOrWhiteSpace(parsed.Uuid)) + { + throw new WangaPayFastException("PayFast onsite process failed: invalid UUID response."); + } + + return parsed.Uuid; + } + + public static string CardUpdateUrl(PayFastEnvironment environment, string token, string? returnUrl = null) + { + ArgumentException.ThrowIfNullOrWhiteSpace(token); + + var baseUrl = environment == PayFastEnvironment.Sandbox + ? "https://sandbox.payfast.co.za/eng/recurring/update" + : "https://www.payfast.co.za/eng/recurring/update"; + + if (string.IsNullOrWhiteSpace(returnUrl)) + { + return $"{baseUrl}/{token}"; + } + + var encoded = Uri.EscapeDataString(returnUrl).Replace("%20", "+", StringComparison.Ordinal); + return $"{baseUrl}/{token}?return={encoded}"; + } +} diff --git a/wangapayfast-csharp/PayFastModels.cs b/wangapayfast-csharp/PayFastModels.cs index 4c547a0..f5cc80f 100644 --- a/wangapayfast-csharp/PayFastModels.cs +++ b/wangapayfast-csharp/PayFastModels.cs @@ -8,6 +8,14 @@ public enum PayFastEnvironment Sandbox } +public enum ItnPaymentStatus +{ + Unknown, + Complete, + Failed, + Cancelled +} + public sealed class PayFastConfig { public string? MerchantId { get; set; } @@ -99,3 +107,9 @@ public sealed class CheckoutResponse public string Url { get; set; } = string.Empty; public SortedDictionary Params { get; set; } = new(StringComparer.Ordinal); } + +public sealed class OnsiteProcessResponse +{ + [JsonPropertyName("uuid")] + public string Uuid { get; set; } = string.Empty; +} diff --git a/wangapayfast-csharp/README.md b/wangapayfast-csharp/README.md index 0106148..1793c38 100644 --- a/wangapayfast-csharp/README.md +++ b/wangapayfast-csharp/README.md @@ -1,6 +1,8 @@ # wangapayfast-csharp -Helpers for building [PayFast](https://www.payfast.co.za/) checkout payloads and signatures in .NET services. +Helpers for working with [PayFast](https://www.payfast.co.za/) in .NET services: +checkout payload/signature building, ITN parsing/verification, post-back validation, +and onsite helpers. ## Features @@ -8,6 +10,11 @@ Helpers for building [PayFast](https://www.payfast.co.za/) checkout payloads and - Build subscription checkout payloads - Build custom checkout payloads from arbitrary parameters - Generate PayFast-compatible checkout signatures +- Parse ITN (`application/x-www-form-urlencoded`) payloads +- Verify ITN signatures +- Validate ITN post-back against PayFast (`/eng/query/validate`) +- Generate onsite payment UUIDs (`/onsite/process`) +- Build recurring card-update URLs - Sandbox and live environment support ## Install @@ -42,6 +49,28 @@ var checkout = client.BuildOnceOffCheckout(new OnceOffPaymentRequest // checkout.Params => includes merchant fields + signature ``` +## ITN + HTTP helpers + +```csharp +using WangaPayFast.CSharp; + +// Parse incoming ITN body +var itn = ItnNotification.FromBody(rawBodyString); + +// Verify signature +var signatureOk = PayFastClient.VerifyItnSignature( + itn, + Environment.GetEnvironmentVariable("PAYFAST_PASSPHRASE") +); + +// Optional post-back validation +var isValid = await PayFastHttpHelpers.PostBackValidateItnAsync( + httpClient, + PayFastEnvironment.Sandbox, + rawBodyString +); +``` + ## License MIT diff --git a/wangapayfast-csharp/tests/WangaPayFast.CSharp.Tests/PayFastClientTests.cs b/wangapayfast-csharp/tests/WangaPayFast.CSharp.Tests/PayFastClientTests.cs index 75a993d..05b83ce 100644 --- a/wangapayfast-csharp/tests/WangaPayFast.CSharp.Tests/PayFastClientTests.cs +++ b/wangapayfast-csharp/tests/WangaPayFast.CSharp.Tests/PayFastClientTests.cs @@ -1,4 +1,6 @@ using WangaPayFast.CSharp; +using System.Net; +using System.Text; namespace WangaPayFast.CSharp.Tests; @@ -59,4 +61,87 @@ public void BuildSignaturePayload_ExcludesSetupAndSignatureFields() Assert.Contains("item_name=Hello+World", payload, StringComparison.Ordinal); Assert.EndsWith("passphrase=pass", payload, StringComparison.Ordinal); } + + [Fact] + public void VerifyItnSignature_ValidPayload_ReturnsTrue() + { + var fields = new Dictionary + { + ["m_payment_id"] = "ORDER-1", + ["payment_status"] = "COMPLETE", + ["amount_gross"] = "100.00" + }; + var signature = PayFastClient.GenerateItnSignature(fields, "abc123"); + var raw = $"m_payment_id=ORDER-1&payment_status=COMPLETE&amount_gross=100.00&signature={signature}"; + + var itn = ItnNotification.FromBody(raw); + + Assert.True(PayFastClient.VerifyItnSignature(itn, "abc123")); + Assert.Equal(ItnPaymentStatus.Complete, itn.PaymentStatus()); + Assert.True(itn.IsGrossAmount("100.00")); + } + + [Fact] + public async Task PostBackValidateItnAsync_ValidResponse_ReturnsTrue() + { + var handler = new FakeHandler(_ => new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("VALID", Encoding.UTF8, "text/plain") + }); + using var httpClient = new HttpClient(handler); + + var result = await PayFastHttpHelpers.PostBackValidateItnAsync( + httpClient, + PayFastEnvironment.Sandbox, + "m_payment_id=1&signature=abc"); + + Assert.True(result); + } + + [Fact] + public async Task GeneratePaymentIdentifierAsync_ReturnsUuid() + { + var handler = new FakeHandler(req => + { + Assert.Equal(HttpMethod.Post, req.Method); + Assert.Contains("/onsite/process", req.RequestUri?.AbsoluteUri, StringComparison.Ordinal); + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("{\"uuid\":\"abc-123\"}", Encoding.UTF8, "application/json") + }; + }); + using var httpClient = new HttpClient(handler); + + var uuid = await PayFastHttpHelpers.GeneratePaymentIdentifierAsync( + httpClient, + new PayFastConfig + { + MerchantId = "10000100", + MerchantKey = "46f0cd694581a", + Passphrase = "abc123" + }, + PayFastEnvironment.Sandbox, + new Dictionary + { + ["amount"] = "100.00", + ["item_name"] = "Test" + }); + + Assert.Equal("abc-123", uuid); + } + + private sealed class FakeHandler : HttpMessageHandler + { + private readonly Func _handler; + + public FakeHandler(Func handler) + { + _handler = handler; + } + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + return Task.FromResult(_handler(request)); + } + } } From 7befc72e24f8ee0db63ddd02e19e7ebe645b4a56 Mon Sep 17 00:00:00 2001 From: Mitch Chimwemwe Chanza Date: Thu, 26 Mar 2026 15:17:40 +0200 Subject: [PATCH 03/10] feat(wangamail-csharp): enhance BodyType handling with custom JSON converter - Removed the JsonStringEnumMemberName attribute from BodyType enum. - Implemented BodyTypeJsonConverter for custom serialization and deserialization of BodyType values. - Updated MessageBody to use the new BodyTypeJsonConverter for ContentType property. - Modified project file to exclude test files from compilation. --- wangamail-csharp/GraphModels.cs | 22 ++++++++++++++++++++-- wangamail-csharp/WangaMail.CSharp.csproj | 4 ++++ 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/wangamail-csharp/GraphModels.cs b/wangamail-csharp/GraphModels.cs index a491205..6b771cc 100644 --- a/wangamail-csharp/GraphModels.cs +++ b/wangamail-csharp/GraphModels.cs @@ -5,7 +5,6 @@ namespace WangaMail.CSharp; public enum BodyType { Text, - [JsonStringEnumMemberName("HTML")] Html } @@ -38,13 +37,32 @@ public static Recipient Create(string address, string? name = null) public sealed class MessageBody { [JsonPropertyName("contentType")] - [JsonConverter(typeof(JsonStringEnumConverter))] + [JsonConverter(typeof(BodyTypeJsonConverter))] public BodyType ContentType { get; set; } = BodyType.Text; [JsonPropertyName("content")] public string Content { get; set; } = string.Empty; } +public sealed class BodyTypeJsonConverter : JsonConverter +{ + public override BodyType Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + var value = reader.GetString(); + return value?.ToUpperInvariant() switch + { + "TEXT" => BodyType.Text, + "HTML" => BodyType.Html, + _ => throw new JsonException($"Invalid BodyType value: {value}") + }; + } + + public override void Write(Utf8JsonWriter writer, BodyType value, JsonSerializerOptions options) + { + writer.WriteStringValue(value == BodyType.Html ? "HTML" : "Text"); + } +} + public sealed class FileAttachment { [JsonPropertyName("@odata.type")] diff --git a/wangamail-csharp/WangaMail.CSharp.csproj b/wangamail-csharp/WangaMail.CSharp.csproj index f98e643..5034495 100644 --- a/wangamail-csharp/WangaMail.CSharp.csproj +++ b/wangamail-csharp/WangaMail.CSharp.csproj @@ -17,4 +17,8 @@ + + + + From fb23e404aec2759f53dc4e2fc449a57bdce35b0b Mon Sep 17 00:00:00 2001 From: Mitch Chimwemwe Chanza Date: Thu, 26 Mar 2026 15:18:24 +0200 Subject: [PATCH 04/10] feat(wangamail-py): add initial implementation of email client using Microsoft Graph API - Introduced `GraphMailClient` for sending emails with app registration credentials. - Added data models for email payloads, including `Message`, `MessageBody`, `Recipient`, and `SendMailRequest`. - Implemented error handling with custom exceptions: `ConfigError` and `GraphAPIError`. - Created a CLI entry point for manual checks and environment variable support. - Added unit tests to validate email payload structure. - Included package metadata and usage instructions in README. --- wangamail-py/README.md | 45 +++++++++ wangamail-py/package.json | 12 +++ wangamail-py/pyproject.toml | 24 +++++ wangamail-py/tests/test_models.py | 18 ++++ wangamail-py/wangamail_py/__init__.py | 16 +++ wangamail-py/wangamail_py/__main__.py | 12 +++ wangamail-py/wangamail_py/client.py | 134 ++++++++++++++++++++++++++ wangamail-py/wangamail_py/errors.py | 13 +++ wangamail-py/wangamail_py/models.py | 62 ++++++++++++ 9 files changed, 336 insertions(+) create mode 100644 wangamail-py/README.md create mode 100644 wangamail-py/package.json create mode 100644 wangamail-py/pyproject.toml create mode 100644 wangamail-py/tests/test_models.py create mode 100644 wangamail-py/wangamail_py/__init__.py create mode 100644 wangamail-py/wangamail_py/__main__.py create mode 100644 wangamail-py/wangamail_py/client.py create mode 100644 wangamail-py/wangamail_py/errors.py create mode 100644 wangamail-py/wangamail_py/models.py diff --git a/wangamail-py/README.md b/wangamail-py/README.md new file mode 100644 index 0000000..94b9b7f --- /dev/null +++ b/wangamail-py/README.md @@ -0,0 +1,45 @@ +# wangamail-py + +Send email on behalf of a Microsoft tenant using [Microsoft Graph API](https://learn.microsoft.com/en-us/graph/overview) and app registration credentials (OAuth2 client credentials flow). + +## Installation + +```bash +pip install wangamail-py +``` + +## Usage + +```python +from wangamail_py import GraphMailClient, Message, MessageBody, Recipient, SendMailRequest + +client = GraphMailClient( + tenant_id="your-tenant-id", + client_id="your-client-id", + client_secret="your-client-secret", +) + +request = SendMailRequest( + message=Message( + subject="Hello from Graph", + body=MessageBody(content_type="Text", content="This email was sent via Microsoft Graph."), + to_recipients=[Recipient.from_email("recipient@example.com")], + ) +) + +client.send_mail("user@yourtenant.onmicrosoft.com", request) +``` + +## Environment helper + +You can create a client from environment variables: + +- `AZURE_TENANT_ID` +- `AZURE_CLIENT_ID` +- `AZURE_CLIENT_SECRET` + +```python +from wangamail_py import GraphMailClient + +client = GraphMailClient.from_env() +``` diff --git a/wangamail-py/package.json b/wangamail-py/package.json new file mode 100644 index 0000000..bcbf073 --- /dev/null +++ b/wangamail-py/package.json @@ -0,0 +1,12 @@ +{ + "name": "wangamail-py", + "version": "0.1.0", + "private": true, + "description": "Python package metadata for monorepo tooling", + "scripts": { + "dev": "python -m wangamail_py", + "build": "python -m build", + "test": "pytest", + "lint": "python -m py_compile wangamail_py/*.py" + } +} diff --git a/wangamail-py/pyproject.toml b/wangamail-py/pyproject.toml new file mode 100644 index 0000000..10ee352 --- /dev/null +++ b/wangamail-py/pyproject.toml @@ -0,0 +1,24 @@ +[build-system] +requires = ["hatchling>=1.25.0"] +build-backend = "hatchling.build" + +[project] +name = "wangamail-py" +version = "0.1.0" +description = "Send email via Microsoft Graph API using app registration credentials" +readme = "README.md" +requires-python = ">=3.9" +license = { text = "MIT OR Apache-2.0" } +authors = [ + { name = "1nga Solutions" } +] +keywords = ["wangamail", "microsoft", "graph", "email", "oauth2"] +dependencies = [ +] + +[project.urls] +Homepage = "https://github.com/1ngaOS/libs" +Repository = "https://github.com/1ngaOS/libs/tree/main/wangamail-py" + +[tool.pytest.ini_options] +testpaths = ["tests"] diff --git a/wangamail-py/tests/test_models.py b/wangamail-py/tests/test_models.py new file mode 100644 index 0000000..7795c87 --- /dev/null +++ b/wangamail-py/tests/test_models.py @@ -0,0 +1,18 @@ +from wangamail_py import Message, MessageBody, Recipient, SendMailRequest + + +def test_send_mail_payload_shape() -> None: + payload = SendMailRequest( + message=Message( + subject="Hello", + body=MessageBody(content_type="Text", content="Hi"), + to_recipients=[Recipient.from_email("to@example.com")], + cc_recipients=[Recipient.from_email("cc@example.com")], + bcc_recipients=[Recipient.from_email("bcc@example.com")], + ) + ).to_graph() + + assert payload["saveToSentItems"] is True + assert payload["message"]["subject"] == "Hello" + assert payload["message"]["body"]["contentType"] == "Text" + assert payload["message"]["toRecipients"][0]["emailAddress"]["address"] == "to@example.com" diff --git a/wangamail-py/wangamail_py/__init__.py b/wangamail-py/wangamail_py/__init__.py new file mode 100644 index 0000000..93f66a7 --- /dev/null +++ b/wangamail-py/wangamail_py/__init__.py @@ -0,0 +1,16 @@ +"""wangamail-py package exports.""" + +from .client import GraphMailClient +from .errors import ConfigError, GraphAPIError, WangaMailError +from .models import Message, MessageBody, Recipient, SendMailRequest + +__all__ = [ + "GraphMailClient", + "WangaMailError", + "ConfigError", + "GraphAPIError", + "Message", + "MessageBody", + "Recipient", + "SendMailRequest", +] diff --git a/wangamail-py/wangamail_py/__main__.py b/wangamail-py/wangamail_py/__main__.py new file mode 100644 index 0000000..623fcd5 --- /dev/null +++ b/wangamail-py/wangamail_py/__main__.py @@ -0,0 +1,12 @@ +"""Small CLI entrypoint for manual checks.""" + +from .client import GraphMailClient + + +def main() -> None: + GraphMailClient.from_env() + print("wangamail-py client initialized from environment") + + +if __name__ == "__main__": + main() diff --git a/wangamail-py/wangamail_py/client.py b/wangamail-py/wangamail_py/client.py new file mode 100644 index 0000000..c9365c0 --- /dev/null +++ b/wangamail-py/wangamail_py/client.py @@ -0,0 +1,134 @@ +"""Graph mail client implementation for wangamail-py.""" + +from __future__ import annotations + +import os +import time +from dataclasses import dataclass +from urllib.parse import quote + +import json +from urllib.error import HTTPError, URLError +from urllib.request import Request, urlopen + +from .errors import ConfigError, GraphAPIError +from .models import SendMailRequest + +DEFAULT_SCOPE = "https://graph.microsoft.com/.default" +DEFAULT_GRAPH_BASE = "https://graph.microsoft.com/v1.0" + + +@dataclass(slots=True) +class _TokenState: + access_token: str | None = None + expires_at: float = 0.0 + + +class GraphMailClient: + """Client for sending email via Microsoft Graph using app credentials.""" + + def __init__( + self, + *, + tenant_id: str, + client_id: str, + client_secret: str, + token_url: str | None = None, + graph_base: str = DEFAULT_GRAPH_BASE, + scope: str = DEFAULT_SCOPE, + timeout: float = 20.0, + ) -> None: + if not tenant_id: + raise ConfigError("tenant_id is required") + if not client_id: + raise ConfigError("client_id is required") + if not client_secret: + raise ConfigError("client_secret is required") + + self._tenant_id = tenant_id + self._client_id = client_id + self._client_secret = client_secret + self._token_url = token_url or f"https://login.microsoftonline.com/{tenant_id}/oauth2/v2.0/token" + self._graph_base = graph_base.rstrip("/") + self._scope = scope + self._timeout = timeout + self._token_state = _TokenState() + + @classmethod + def from_env(cls) -> "GraphMailClient": + """Create a client from AZURE_* environment variables.""" + return cls( + tenant_id=os.environ.get("AZURE_TENANT_ID", ""), + client_id=os.environ.get("AZURE_CLIENT_ID", ""), + client_secret=os.environ.get("AZURE_CLIENT_SECRET", ""), + ) + + def _get_token(self) -> str: + now = time.time() + if self._token_state.access_token and now < self._token_state.expires_at: + return self._token_state.access_token + + form_data = ( + "client_id=" + + quote(self._client_id, safe="") + + "&client_secret=" + + quote(self._client_secret, safe="") + + "&scope=" + + quote(self._scope, safe="") + + "&grant_type=client_credentials" + ) + req = Request( + self._token_url, + data=form_data.encode("utf-8"), + headers={"Content-Type": "application/x-www-form-urlencoded"}, + method="POST", + ) + try: + with urlopen(req, timeout=self._timeout) as response: + payload = json.loads(response.read().decode("utf-8")) + except HTTPError as exc: + body = exc.read().decode("utf-8", errors="replace") + raise GraphAPIError(f"token request failed: {exc.code} {body}") from exc + except URLError as exc: + raise GraphAPIError(f"token request failed: {exc}") from exc + + token = payload.get("access_token") + expires_in = int(payload.get("expires_in", 300)) + if not token: + raise GraphAPIError("token response did not include access_token") + + self._token_state = _TokenState( + access_token=token, + expires_at=now + max(expires_in - 60, 30), + ) + return token + + def send_mail(self, from_user: str, request: SendMailRequest) -> None: + """Send an email as the given user (user id or userPrincipalName).""" + if not from_user: + raise ConfigError("from_user is required") + + token = self._get_token() + url = f"{self._graph_base}/users/{quote(from_user, safe='')}/sendMail" + + payload = json.dumps(request.to_graph()).encode("utf-8") + req = Request( + url, + data=payload, + headers={ + "Authorization": f"Bearer {token}", + "Content-Type": "application/json", + }, + method="POST", + ) + try: + with urlopen(req, timeout=self._timeout) as response: + status = getattr(response, "status", response.getcode()) + except HTTPError as exc: + body = exc.read().decode("utf-8", errors="replace") + raise GraphAPIError(f"sendMail failed: {exc.code} {body}") from exc + except URLError as exc: + raise GraphAPIError(f"sendMail failed: {exc}") from exc + + if status != 202: + raise GraphAPIError(f"sendMail failed: {status}") diff --git a/wangamail-py/wangamail_py/errors.py b/wangamail-py/wangamail_py/errors.py new file mode 100644 index 0000000..12240d8 --- /dev/null +++ b/wangamail-py/wangamail_py/errors.py @@ -0,0 +1,13 @@ +"""Custom exceptions for wangamail-py.""" + + +class WangaMailError(Exception): + """Base exception for wangamail-py.""" + + +class ConfigError(WangaMailError): + """Raised when client configuration is invalid or missing.""" + + +class GraphAPIError(WangaMailError): + """Raised when Graph API returns a non-successful response.""" diff --git a/wangamail-py/wangamail_py/models.py b/wangamail-py/wangamail_py/models.py new file mode 100644 index 0000000..9292eef --- /dev/null +++ b/wangamail-py/wangamail_py/models.py @@ -0,0 +1,62 @@ +"""Data models for Microsoft Graph sendMail payloads.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Dict, List, Literal + +BodyType = Literal["Text", "HTML"] + + +@dataclass(slots=True) +class Recipient: + email: str + + @classmethod + def from_email(cls, email: str) -> "Recipient": + return cls(email=email) + + def to_graph(self) -> Dict[str, Dict[str, str]]: + return {"emailAddress": {"address": self.email}} + + +@dataclass(slots=True) +class MessageBody: + content_type: BodyType + content: str + + def to_graph(self) -> Dict[str, str]: + return {"contentType": self.content_type, "content": self.content} + + +@dataclass(slots=True) +class Message: + subject: str + body: MessageBody + to_recipients: List[Recipient] = field(default_factory=list) + cc_recipients: List[Recipient] = field(default_factory=list) + bcc_recipients: List[Recipient] = field(default_factory=list) + + def to_graph(self) -> Dict[str, object]: + payload: Dict[str, object] = { + "subject": self.subject, + "body": self.body.to_graph(), + "toRecipients": [r.to_graph() for r in self.to_recipients], + } + if self.cc_recipients: + payload["ccRecipients"] = [r.to_graph() for r in self.cc_recipients] + if self.bcc_recipients: + payload["bccRecipients"] = [r.to_graph() for r in self.bcc_recipients] + return payload + + +@dataclass(slots=True) +class SendMailRequest: + message: Message + save_to_sent_items: bool = True + + def to_graph(self) -> Dict[str, object]: + return { + "message": self.message.to_graph(), + "saveToSentItems": self.save_to_sent_items, + } From 066bc1418032932c071e5de104047e95301a6b11 Mon Sep 17 00:00:00 2001 From: Mitch Chimwemwe Chanza Date: Thu, 26 Mar 2026 15:22:11 +0200 Subject: [PATCH 05/10] feat(wangamail-csharp): add System.Text.Json support for serialization - Introduced `System.Text.Json` namespace to `GraphModels.cs` for enhanced JSON handling capabilities. --- wangamail-csharp/GraphModels.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/wangamail-csharp/GraphModels.cs b/wangamail-csharp/GraphModels.cs index 6b771cc..5c59e16 100644 --- a/wangamail-csharp/GraphModels.cs +++ b/wangamail-csharp/GraphModels.cs @@ -1,3 +1,4 @@ +using System.Text.Json; using System.Text.Json.Serialization; namespace WangaMail.CSharp; From 848ffaa7c2cb9961025333299aa2ee88974b65ba Mon Sep 17 00:00:00 2001 From: Mitch Chimwemwe Chanza Date: Thu, 26 Mar 2026 15:38:46 +0200 Subject: [PATCH 06/10] fix(workflows): update test command to use --no-restore option - Modified the test commands in both `wangamail-csharp.yml` and `wangapayfast-csharp.yml` workflows to replace `--no-build` with `--no-restore`, improving efficiency by avoiding unnecessary restore operations during testing. --- .github/workflows/wangamail-csharp.yml | 2 +- .github/workflows/wangapayfast-csharp.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/wangamail-csharp.yml b/.github/workflows/wangamail-csharp.yml index a5cf349..92f4a7e 100644 --- a/.github/workflows/wangamail-csharp.yml +++ b/.github/workflows/wangamail-csharp.yml @@ -75,7 +75,7 @@ jobs: dotnet restore wangamail-csharp/WangaMail.CSharp.csproj dotnet restore wangamail-csharp/tests/WangaMail.CSharp.Tests/WangaMail.CSharp.Tests.csproj - name: Test - run: dotnet test wangamail-csharp/tests/WangaMail.CSharp.Tests/WangaMail.CSharp.Tests.csproj -c Release --no-build + run: dotnet test wangamail-csharp/tests/WangaMail.CSharp.Tests/WangaMail.CSharp.Tests.csproj -c Release --no-restore - name: Job summary if: always() run: | diff --git a/.github/workflows/wangapayfast-csharp.yml b/.github/workflows/wangapayfast-csharp.yml index f7532ee..0d16ae6 100644 --- a/.github/workflows/wangapayfast-csharp.yml +++ b/.github/workflows/wangapayfast-csharp.yml @@ -75,7 +75,7 @@ jobs: dotnet restore wangapayfast-csharp/WangaPayFast.CSharp.csproj dotnet restore wangapayfast-csharp/tests/WangaPayFast.CSharp.Tests/WangaPayFast.CSharp.Tests.csproj - name: Test - run: dotnet test wangapayfast-csharp/tests/WangaPayFast.CSharp.Tests/WangaPayFast.CSharp.Tests.csproj -c Release --no-build + run: dotnet test wangapayfast-csharp/tests/WangaPayFast.CSharp.Tests/WangaPayFast.CSharp.Tests.csproj -c Release --no-restore - name: Job summary if: always() run: | From acc4ba3051bad1d5553a0fa3196933dbb0f84e36 Mon Sep 17 00:00:00 2001 From: Mitch Chimwemwe Chanza Date: Thu, 26 Mar 2026 15:45:05 +0200 Subject: [PATCH 07/10] fix(tests): add Xunit reference and exclude test files from compilation - Added `Xunit` namespace to `GraphModelsTests.cs` for unit testing support. - Updated project file to exclude test files from compilation, improving build efficiency. --- .../tests/WangaMail.CSharp.Tests/GraphModelsTests.cs | 1 + wangapayfast-csharp/WangaPayFast.CSharp.csproj | 4 ++++ 2 files changed, 5 insertions(+) diff --git a/wangamail-csharp/tests/WangaMail.CSharp.Tests/GraphModelsTests.cs b/wangamail-csharp/tests/WangaMail.CSharp.Tests/GraphModelsTests.cs index 6ffea84..5b7af54 100644 --- a/wangamail-csharp/tests/WangaMail.CSharp.Tests/GraphModelsTests.cs +++ b/wangamail-csharp/tests/WangaMail.CSharp.Tests/GraphModelsTests.cs @@ -1,5 +1,6 @@ using System.Text.Json; using WangaMail.CSharp; +using Xunit; namespace WangaMail.CSharp.Tests; diff --git a/wangapayfast-csharp/WangaPayFast.CSharp.csproj b/wangapayfast-csharp/WangaPayFast.CSharp.csproj index 16c62ce..b4a978b 100644 --- a/wangapayfast-csharp/WangaPayFast.CSharp.csproj +++ b/wangapayfast-csharp/WangaPayFast.CSharp.csproj @@ -17,4 +17,8 @@ + + + + From 345e243d687635ba66206b92d4f783d3ba936dbd Mon Sep 17 00:00:00 2001 From: Mitch Chimwemwe Chanza Date: Thu, 26 Mar 2026 15:51:33 +0200 Subject: [PATCH 08/10] fix(payfast): update hash conversion to use ToLowerInvariant for consistency - Changed the hash conversion method in `PayFastClient` to use `Convert.ToHexString(hash).ToLowerInvariant()` for consistent lowercase output in both signature generation methods. --- wangapayfast-csharp/PayFastClient.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/wangapayfast-csharp/PayFastClient.cs b/wangapayfast-csharp/PayFastClient.cs index f34bb45..9133f8f 100644 --- a/wangapayfast-csharp/PayFastClient.cs +++ b/wangapayfast-csharp/PayFastClient.cs @@ -176,7 +176,7 @@ internal static string GenerateCheckoutSignature( var payload = BuildSignaturePayload(parameters, passphrase); var bytes = Encoding.UTF8.GetBytes(payload); var hash = MD5.HashData(bytes); - return Convert.ToHexStringLower(hash); + return Convert.ToHexString(hash).ToLowerInvariant(); } internal static string BuildSignaturePayload( @@ -200,7 +200,7 @@ public static string GenerateItnSignature( var payload = BuildItnSignaturePayload(parameters, passphrase); var bytes = Encoding.UTF8.GetBytes(payload); var hash = MD5.HashData(bytes); - return Convert.ToHexStringLower(hash); + return Convert.ToHexString(hash).ToLowerInvariant(); } public static bool VerifyItnSignature( From 1842e8e312ddd324888d949ae6f68fd35e34e6be Mon Sep 17 00:00:00 2001 From: Mitch Chimwemwe Chanza Date: Thu, 26 Mar 2026 15:55:05 +0200 Subject: [PATCH 09/10] fix(tests): add Xunit reference to PayFastClientTests - Added `Xunit` namespace to `PayFastClientTests.cs` to support unit testing functionality. --- .../tests/WangaPayFast.CSharp.Tests/PayFastClientTests.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/wangapayfast-csharp/tests/WangaPayFast.CSharp.Tests/PayFastClientTests.cs b/wangapayfast-csharp/tests/WangaPayFast.CSharp.Tests/PayFastClientTests.cs index 05b83ce..46b7aaf 100644 --- a/wangapayfast-csharp/tests/WangaPayFast.CSharp.Tests/PayFastClientTests.cs +++ b/wangapayfast-csharp/tests/WangaPayFast.CSharp.Tests/PayFastClientTests.cs @@ -1,6 +1,7 @@ using WangaPayFast.CSharp; using System.Net; using System.Text; +using Xunit; namespace WangaPayFast.CSharp.Tests; From 4485e3681160cfb5e91ea35c13ef2395219900bd Mon Sep 17 00:00:00 2001 From: Mitch Chimwemwe Chanza Date: Thu, 26 Mar 2026 23:38:56 +0200 Subject: [PATCH 10/10] refactor(payfast): change signature payload methods to public access - Updated `BuildSignaturePayload` and `BuildItnSignaturePayload` methods in `PayFastClient` from internal to public access, allowing for broader usage in external contexts. --- wangapayfast-csharp/PayFastClient.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/wangapayfast-csharp/PayFastClient.cs b/wangapayfast-csharp/PayFastClient.cs index 9133f8f..82017c4 100644 --- a/wangapayfast-csharp/PayFastClient.cs +++ b/wangapayfast-csharp/PayFastClient.cs @@ -179,14 +179,14 @@ internal static string GenerateCheckoutSignature( return Convert.ToHexString(hash).ToLowerInvariant(); } - internal static string BuildSignaturePayload( + public static string BuildSignaturePayload( IReadOnlyDictionary parameters, string? passphrase = null) { return BuildSignaturePayload(parameters, passphrase, excludeSetupField: true); } - internal static string BuildItnSignaturePayload( + public static string BuildItnSignaturePayload( IReadOnlyDictionary parameters, string? passphrase = null) {