diff --git a/.github/workflows/wangamail-csharp.yml b/.github/workflows/wangamail-csharp.yml
new file mode 100644
index 0000000..92f4a7e
--- /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-restore
+ - 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..0d16ae6
--- /dev/null
+++ b/.github/workflows/wangapayfast-csharp.yml
@@ -0,0 +1,365 @@
+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-restore
+ - 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 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/.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..5c59e16
--- /dev/null
+++ b/wangamail-csharp/GraphModels.cs
@@ -0,0 +1,114 @@
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace WangaMail.CSharp;
+
+public enum BodyType
+{
+ Text,
+ 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(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")]
+ 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..5034495
--- /dev/null
+++ b/wangamail-csharp/WangaMail.CSharp.csproj
@@ -0,0 +1,24 @@
+
+
+ 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..5b7af54
--- /dev/null
+++ b/wangamail-csharp/tests/WangaMail.CSharp.Tests/GraphModelsTests.cs
@@ -0,0 +1,40 @@
+using System.Text.Json;
+using WangaMail.CSharp;
+using Xunit;
+
+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/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,
+ }
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
new file mode 100644
index 0000000..82017c4
--- /dev/null
+++ b/wangapayfast-csharp/PayFastClient.cs
@@ -0,0 +1,240 @@
+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.ToHexString(hash).ToLowerInvariant();
+ }
+
+ public static string BuildSignaturePayload(
+ IReadOnlyDictionary parameters,
+ string? passphrase = null)
+ {
+ return BuildSignaturePayload(parameters, passphrase, excludeSetupField: true);
+ }
+
+ public 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.ToHexString(hash).ToLowerInvariant();
+ }
+
+ 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 => !excludeSetupField || !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/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
new file mode 100644
index 0000000..f5cc80f
--- /dev/null
+++ b/wangapayfast-csharp/PayFastModels.cs
@@ -0,0 +1,115 @@
+using System.Text.Json.Serialization;
+
+namespace WangaPayFast.CSharp;
+
+public enum PayFastEnvironment
+{
+ Live,
+ Sandbox
+}
+
+public enum ItnPaymentStatus
+{
+ Unknown,
+ Complete,
+ Failed,
+ Cancelled
+}
+
+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);
+}
+
+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
new file mode 100644
index 0000000..1793c38
--- /dev/null
+++ b/wangapayfast-csharp/README.md
@@ -0,0 +1,76 @@
+# wangapayfast-csharp
+
+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
+
+- Build once-off checkout payloads
+- 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
+
+```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
+```
+
+## 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/WangaPayFast.CSharp.csproj b/wangapayfast-csharp/WangaPayFast.CSharp.csproj
new file mode 100644
index 0000000..b4a978b
--- /dev/null
+++ b/wangapayfast-csharp/WangaPayFast.CSharp.csproj
@@ -0,0 +1,24 @@
+
+
+ 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..46b7aaf
--- /dev/null
+++ b/wangapayfast-csharp/tests/WangaPayFast.CSharp.Tests/PayFastClientTests.cs
@@ -0,0 +1,148 @@
+using WangaPayFast.CSharp;
+using System.Net;
+using System.Text;
+using Xunit;
+
+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);
+ }
+
+ [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));
+ }
+ }
+}
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,