diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..8bb589f --- /dev/null +++ b/.dockerignore @@ -0,0 +1,11 @@ +.git +.github +*.md +LICENSE +examples/ +tests/ +test-email-sender/ +**/bin/ +**/obj/ +.env +.env.* diff --git a/.gitignore b/.gitignore index 35ec13f..0bc623d 100644 --- a/.gitignore +++ b/.gitignore @@ -308,4 +308,6 @@ $RECYCLE.BIN/ tests/CCAI.NET.IntegrationTests/ test_real_webhook.cs test-real-webhook/ -CCAI.NET.IntegrationTests \ No newline at end of file +CCAI.NET.IntegrationTests + +.claude/ diff --git a/README.md b/README.md index 84a9e47..f59007b 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,10 @@ A C# client library for interacting with the [CloudContactAI](https://cloudconta - Send SMS messages to single or multiple recipients - Send MMS messages with images (automatic S3 upload) - Send Email campaigns to single or multiple recipients +- Brand registration and management for TCR verification +- Campaign registration and management for TCR carrier vetting - Manage contact opt-out preferences (SetDoNotText) +- Validate email addresses (valid/invalid/risky) and phone numbers (valid/invalid/landline) - Webhook management: register, list, update, delete - Webhook signature verification - Template variable substitution (`${firstName}`, `${lastName}`) @@ -278,6 +281,38 @@ await ccai.Contact.SetDoNotTextAsync(false, phone: "+15551234567"); await ccai.Contact.SetDoNotTextAsync(true, contactId: "contact-abc-123"); ``` +### Contact Validator + +Validate email addresses and phone numbers. + +```csharp +using CCAI.NET; +using CCAI.NET.ContactValidator; + +// Validate a single email +var emailResult = await ccai.ContactValidator.ValidateEmailAsync("user@example.com"); +Console.WriteLine(emailResult.Status); // "valid" | "invalid" | "risky" + +// Validate multiple emails (up to 50) +var bulkEmails = await ccai.ContactValidator.ValidateEmailsAsync(new[] { + "user@example.com", + "bad@invalid.xyz" +}); +Console.WriteLine(bulkEmails.Summary.Total); // 2 +Console.WriteLine(bulkEmails.Summary.Valid); // 1 + +// Validate a single phone number +var phoneResult = await ccai.ContactValidator.ValidatePhoneAsync("+15551234567", "US"); +Console.WriteLine(phoneResult.Status); // "valid" | "invalid" | "landline" + +// Validate multiple phone numbers (up to 50) +var bulkPhones = await ccai.ContactValidator.ValidatePhonesAsync(new[] { + new PhoneInput { Phone = "+15551234567" }, + new PhoneInput { Phone = "+15559876543", CountryCode = "US" } +}); +Console.WriteLine(bulkPhones.Summary.Landline); // 1 +``` + ### Webhook Management #### CloudContact Webhook Events (New Format) @@ -452,6 +487,139 @@ else } ``` +### Brand Registration + +Register and manage brands for TCR (The Campaign Registry) business verification. + +```csharp +using CCAI.NET; +using CCAI.NET.Brands; + +var ccai = new CCAIClient(new CCAIConfig +{ + ClientId = "YOUR-CLIENT-ID", + ApiKey = "YOUR-API-KEY" +}); + +// Create a brand +var brand = await ccai.Brands.CreateAsync(new BrandRequest +{ + LegalCompanyName = "Collect.org Inc.", + Dba = "Collect", + EntityType = "NON_PROFIT", + TaxId = "123456789", + TaxIdCountry = "US", + Country = "US", + VerticalType = "NON_PROFIT", + WebsiteUrl = "https://www.collect.org", + Street = "123 Main Street", + City = "San Francisco", + State = "CA", + PostalCode = "94105", + ContactFirstName = "Jane", + ContactLastName = "Doe", + ContactEmail = "jane@collect.org", + ContactPhone = "+14155551234" +}); +Console.WriteLine($"Brand created with ID: {brand.Id}"); + +// Get a brand by ID +var fetched = await ccai.Brands.GetAsync(brand.Id); +Console.WriteLine($"Website match score: {fetched.WebsiteMatchScore?.ToString() ?? "pending"}"); + +// List all brands for the account +var brands = await ccai.Brands.ListAsync(); +Console.WriteLine($"Found {brands.Length} brand(s)"); + +// Update a brand (partial update) +var updated = await ccai.Brands.UpdateAsync(brand.Id, new BrandRequest +{ + Street = "456 Oak Avenue", + City = "Los Angeles" +}); + +// Delete a brand +await ccai.Brands.DeleteAsync(brand.Id); +``` + +#### Entity Types + +`PRIVATE_PROFIT`, `PUBLIC_PROFIT`, `NON_PROFIT`, `GOVERNMENT`, `SOLE_PROPRIETOR` + +> Note: `PUBLIC_PROFIT` entities require `StockSymbol` and `StockExchange` fields. + +#### Vertical Types + +`AUTOMOTIVE`, `AGRICULTURE`, `BANKING`, `COMMUNICATION`, `CONSTRUCTION`, `EDUCATION`, `ENERGY`, `ENTERTAINMENT`, `GOVERNMENT`, `HEALTHCARE`, `HOSPITALITY`, `INSURANCE`, `LEGAL`, `MANUFACTURING`, `NON_PROFIT`, `PROFESSIONAL`, `REAL_ESTATE`, `RETAIL`, `TECHNOLOGY`, `TRANSPORTATION` + +### Campaign Registration + +Register and manage campaigns for TCR (The Campaign Registry) carrier vetting. Each campaign must be linked to a verified brand. + +```csharp +using CCAI.NET; +using CCAI.NET.Campaigns; + +var ccai = new CCAIClient(new CCAIConfig +{ + ClientId = "YOUR-CLIENT-ID", + ApiKey = "YOUR-API-KEY" +}); + +// Create a campaign +var campaign = await ccai.Campaigns.CreateAsync(new CampaignRequest +{ + BrandId = 1, + UseCase = "MIXED", + SubUseCases = new List { "CUSTOMER_CARE", "TWO_FACTOR_AUTHENTICATION", "ACCOUNT_NOTIFICATION" }, + Description = "Security codes and support messaging.", + MessageFlow = "Users opt-in via signup form at https://example.com/signup", + HasEmbeddedLinks = true, + HasEmbeddedPhone = false, + IsAgeGated = false, + IsDirectLending = false, + OptInKeywords = new List { "START" }, + OptInMessage = "Welcome! Reply STOP to cancel.", + OptInProofUrl = "https://example.com/opt-in-proof.png", + HelpKeywords = new List { "HELP" }, + HelpMessage = "For HELP email support@example.com.", + OptOutKeywords = new List { "STOP" }, + OptOutMessage = "STOP received. You are unsubscribed.", + SampleMessages = new List + { + "Your code is 554321. Reply STOP to cancel.", + "Your ticket has been updated. Reply HELP for info." + } +}); +Console.WriteLine($"Campaign created with ID: {campaign.Id}"); + +// Get a campaign by ID +var fetched = await ccai.Campaigns.GetAsync(campaign.Id); + +// List all campaigns for the account +var campaigns = await ccai.Campaigns.ListAsync(); +Console.WriteLine($"Found {campaigns.Length} campaign(s)"); + +// Update a campaign (partial update) +var updated = await ccai.Campaigns.UpdateAsync(campaign.Id, new CampaignRequest +{ + Description = "Updated description." +}); + +// Delete a campaign +await ccai.Campaigns.DeleteAsync(campaign.Id); +``` + +#### Use Cases + +`TWO_FACTOR_AUTHENTICATION`, `ACCOUNT_NOTIFICATION`, `CUSTOMER_CARE`, `DELIVERY_NOTIFICATION`, `FRAUD_ALERT`, `HIGHER_EDUCATION`, `LOW_VOLUME_MIXED`, `MARKETING`, `MIXED`, `POLLING_VOTING`, `PUBLIC_SERVICE_ANNOUNCEMENT`, `SECURITY_ALERT` + +> Note: `MIXED` and `LOW_VOLUME_MIXED` campaigns require 2–3 `SubUseCases`. + +#### Sub-Use Cases + +`TWO_FACTOR_AUTHENTICATION`, `ACCOUNT_NOTIFICATION`, `CUSTOMER_CARE`, `DELIVERY_NOTIFICATION`, `FRAUD_ALERT`, `MARKETING`, `POLLING_VOTING` + ### Step-by-Step MMS Workflow ```csharp diff --git a/RELEASE-NOTES-v1.5.0.md b/RELEASE-NOTES-v1.5.0.md new file mode 100644 index 0000000..5daddd0 --- /dev/null +++ b/RELEASE-NOTES-v1.5.0.md @@ -0,0 +1,10 @@ +# Release Notes - Version 1.5.0 + +## New Features +- Added `ContactValidator` service for validating email addresses and phone numbers +- `ValidateEmailAsync` / `ValidateEmail` — validate a single email, returns `valid`, `invalid`, or `risky` status with `safe_to_send` and `ai_verdict` metadata +- `ValidateEmailsAsync` / `ValidateEmails` — bulk email validation (up to 50 addresses) with summary counts +- `ValidatePhoneAsync` / `ValidatePhone` — validate a single phone number, returns `valid`, `invalid`, or `landline` status with carrier metadata +- `ValidatePhonesAsync` / `ValidatePhones` — bulk phone validation (up to 50 numbers) with summary counts including landline count +- New models: `EmailValidationResult`, `PhoneValidationResult`, `ValidationSummary`, `BulkEmailValidationResult`, `BulkPhoneValidationResult`, `PhoneInput` +- `IContactValidatorService` interface for dependency injection and testing diff --git a/examples/BrandExample.cs b/examples/BrandExample.cs new file mode 100644 index 0000000..9c0b72e --- /dev/null +++ b/examples/BrandExample.cs @@ -0,0 +1,88 @@ +// Copyright (c) 2025 CloudContactAI LLC +// Licensed under the MIT License. See LICENSE in the project root for license information. + +using System; +using System.Threading.Tasks; +using CCAI.NET; +using CCAI.NET.Brands; +using DotNetEnv; + +namespace CCAI.NET.Examples; + +/// +/// Brand registration example using the CCAI.NET client +/// +public class BrandExample +{ + public static async Task RunAsync() + { + Env.Load("./.env"); + + var config = new CCAIConfig + { + ClientId = Environment.GetEnvironmentVariable("CCAI_CLIENT_ID") ?? + throw new InvalidOperationException("CCAI_CLIENT_ID not found"), + ApiKey = Environment.GetEnvironmentVariable("CCAI_API_KEY") ?? + throw new InvalidOperationException("CCAI_API_KEY not found"), + UseTestEnvironment = true + }; + + using var ccai = new CCAIClient(config); + + try + { + // Create a brand + Console.WriteLine("Creating a brand..."); + var brand = await ccai.Brands.CreateAsync(new BrandRequest + { + LegalCompanyName = "Collect.org Inc.", + Dba = "Collect", + EntityType = "NON_PROFIT", + TaxId = "123456789", + TaxIdCountry = "US", + Country = "US", + VerticalType = "NON_PROFIT", + WebsiteUrl = "https://www.collect.org", + Street = "123 Main Street", + City = "San Francisco", + State = "CA", + PostalCode = "94105", + ContactFirstName = "Jane", + ContactLastName = "Doe", + ContactEmail = "jane@collect.org", + ContactPhone = "+14155551234" + }); + Console.WriteLine($"Brand created with ID: {brand.Id}"); + + // Get brand by ID + Console.WriteLine("\nFetching brand by ID..."); + var fetched = await ccai.Brands.GetAsync(brand.Id); + Console.WriteLine($"Brand: {fetched.LegalCompanyName}, Score: {fetched.WebsiteMatchScore?.ToString() ?? "pending"}"); + + // List all brands + Console.WriteLine("\nListing all brands..."); + var brands = await ccai.Brands.ListAsync(); + Console.WriteLine($"Found {brands.Length} brand(s)"); + + // Update a brand + Console.WriteLine("\nUpdating brand..."); + var updated = await ccai.Brands.UpdateAsync(brand.Id, new BrandRequest + { + Street = "456 Oak Avenue", + City = "Los Angeles", + ContactEmail = "admin@collect.org" + }); + Console.WriteLine($"Brand updated: {updated.Street}, {updated.City}"); + + // Delete a brand + Console.WriteLine("\nDeleting brand..."); + await ccai.Brands.DeleteAsync(brand.Id); + Console.WriteLine("Brand deleted successfully"); + } + catch (Exception ex) + { + Console.WriteLine($"Error: {ex.Message}"); + throw; + } + } +} diff --git a/examples/CampaignExample.cs b/examples/CampaignExample.cs new file mode 100644 index 0000000..bead8d8 --- /dev/null +++ b/examples/CampaignExample.cs @@ -0,0 +1,100 @@ +// Copyright (c) 2025 CloudContactAI LLC +// Licensed under the MIT License. See LICENSE in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using CCAI.NET; +using CCAI.NET.Campaigns; +using DotNetEnv; + +namespace CCAI.NET.Examples; + +/// +/// Campaign registration example using the CCAI.NET client +/// +public class CampaignExample +{ + public static async Task RunAsync() + { + Env.Load("./.env"); + + var config = new CCAIConfig + { + ClientId = Environment.GetEnvironmentVariable("CCAI_CLIENT_ID") ?? + throw new InvalidOperationException("CCAI_CLIENT_ID not found"), + ApiKey = Environment.GetEnvironmentVariable("CCAI_API_KEY") ?? + throw new InvalidOperationException("CCAI_API_KEY not found"), + UseTestEnvironment = true + }; + + using var ccai = new CCAIClient(config); + + try + { + // Create a campaign (assumes brand ID 1 exists) + Console.WriteLine("Creating a campaign..."); + var campaign = await ccai.Campaigns.CreateAsync(new CampaignRequest + { + BrandId = 1, + UseCase = "MIXED", + SubUseCases = new List { "CUSTOMER_CARE", "TWO_FACTOR_AUTHENTICATION", "ACCOUNT_NOTIFICATION" }, + Description = "This campaign handles security codes and support for Collect.org.", + MessageFlow = "Users opt-in via our signup form checkbox at https://collect.org/signup", + TermsLink = "https://collect.org/terms", + PrivacyLink = "https://collect.org/privacy", + HasEmbeddedLinks = true, + HasEmbeddedPhone = false, + IsAgeGated = false, + IsDirectLending = false, + OptInKeywords = new List { "START", "JOIN" }, + OptInMessage = "Welcome to Collect.org! Msg&Data rates may apply. Reply STOP to cancel.", + OptInProofUrl = "https://collect.org/images/opt-in-proof.png", + HelpKeywords = new List { "HELP", "INFO" }, + HelpMessage = "Collect.org: For help email support@collect.org. Reply STOP to cancel.", + OptOutKeywords = new List { "STOP", "UNSUBSCRIBE" }, + OptOutMessage = "Collect.org: You have been unsubscribed. STOP received.", + SampleMessages = new List + { + "Your Collect.org security code is 554321. Reply STOP to cancel.", + "Hi [Name], your ticket #[ID] has been updated. Reply HELP for more info." + } + }); + Console.WriteLine($"Campaign created with ID: {campaign.Id}, fee: ${campaign.MonthlyFee}/mo"); + + // Get campaign by ID + Console.WriteLine("\nFetching campaign by ID..."); + var fetched = await ccai.Campaigns.GetAsync(campaign.Id); + Console.WriteLine($"Campaign: {fetched.UseCase}, Brand: {fetched.BrandId}"); + + // List all campaigns + Console.WriteLine("\nListing all campaigns..."); + var campaigns = await ccai.Campaigns.ListAsync(); + Console.WriteLine($"Found {campaigns.Length} campaign(s)"); + + // Update a campaign + Console.WriteLine("\nUpdating campaign..."); + var updated = await ccai.Campaigns.UpdateAsync(campaign.Id, new CampaignRequest + { + Description = "Updated campaign description for Collect.org messaging.", + SampleMessages = new List + { + "Your Collect.org code is 123456. Reply STOP to opt-out.", + "Your support ticket has been resolved. Reply HELP for more info.", + "Your payment of $50.00 was received. Reply STOP to cancel." + } + }); + Console.WriteLine($"Campaign updated: {updated.Description}"); + + // Delete a campaign + Console.WriteLine("\nDeleting campaign..."); + await ccai.Campaigns.DeleteAsync(campaign.Id); + Console.WriteLine("Campaign deleted successfully"); + } + catch (Exception ex) + { + Console.WriteLine($"Error: {ex.Message}"); + throw; + } + } +} diff --git a/integration/CCAITest.csproj b/integration/CCAITest.csproj new file mode 100644 index 0000000..4527319 --- /dev/null +++ b/integration/CCAITest.csproj @@ -0,0 +1,16 @@ + + + + Exe + net8.0 + enable + enable + CCAITest + + + + + + + + diff --git a/integration/Dockerfile b/integration/Dockerfile new file mode 100644 index 0000000..b885c33 --- /dev/null +++ b/integration/Dockerfile @@ -0,0 +1,16 @@ +FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build + +WORKDIR /sdk +COPY src/CCAI.NET/CCAI.NET.csproj ./src/CCAI.NET/ +COPY integration/CCAITest.csproj ./integration/ +RUN echo "" > RELEASE-NOTES-v1.4.5.md +RUN dotnet restore ./integration/CCAITest.csproj + +COPY src/ ./src/ +COPY integration/ ./integration/ +RUN dotnet build ./integration/CCAITest.csproj -c Release -o /app/out -p:GeneratePackageOnBuild=false + +FROM mcr.microsoft.com/dotnet/runtime:8.0 +WORKDIR /app +COPY --from=build /app/out ./ +CMD ["dotnet", "CCAITest.dll"] diff --git a/integration/Program.cs b/integration/Program.cs new file mode 100644 index 0000000..8d6670c --- /dev/null +++ b/integration/Program.cs @@ -0,0 +1,631 @@ +// .NET SDK integration tests — 42 tests +// Covers: SMS (1-6), MMS (7-17), Email (18-22), Webhook (23-29), Contact (30-31), Brands (32-36), Campaigns (37-42) + +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using CCAI.NET; +using CCAI.NET.Brands; +using CCAI.NET.Campaigns; +using CCAI.NET.Email; +using CCAI.NET.SMS; +using CCAI.NET.Webhook; +using CCAI.NET.ContactValidator; + +int passed = 0; +int failed = 0; + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +async Task Run(string name, Func fn) +{ + try + { + await fn(); + Console.WriteLine($" PASS [{name}]"); + passed++; + } + catch (Exception ex) + { + Console.WriteLine($" FAIL [{name}]: {ex.Message}"); + failed++; + } +} + +string MustEnv(string key) +{ + var val = Environment.GetEnvironmentVariable(key); + if (string.IsNullOrEmpty(val)) + { + Console.Error.WriteLine($"ERROR: required env var {key} is not set"); + Environment.Exit(2); + } + return val!; +} + +string HmacSha256Base64(string secret, string message) +{ + using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret)); + var hash = hmac.ComputeHash(Encoding.UTF8.GetBytes(message)); + return Convert.ToBase64String(hash); +} + +string WriteTempPng() +{ + var pngB64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwADhQGAWjR9awAAAABJRU5ErkJggg=="; + var buf = Convert.FromBase64String(pngB64); + var path = Path.Combine(Path.GetTempPath(), $"ccai_test_{DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()}.png"); + File.WriteAllBytes(path, buf); + return path; +} + +// ── Setup ───────────────────────────────────────────────────────────────────── + +var clientId = MustEnv("CCAI_CLIENT_ID"); +var apiKey = MustEnv("CCAI_API_KEY"); +var phone1 = MustEnv("CCAI_TEST_PHONE"); +var phone2 = MustEnv("CCAI_TEST_PHONE_2"); +var phone3 = MustEnv("CCAI_TEST_PHONE_3"); +var email1 = MustEnv("CCAI_TEST_EMAIL"); +var email2 = MustEnv("CCAI_TEST_EMAIL_2"); +var email3 = MustEnv("CCAI_TEST_EMAIL_3"); +var fn1 = MustEnv("CCAI_TEST_FIRST_NAME"); +var ln1 = MustEnv("CCAI_TEST_LAST_NAME"); +var fn2 = MustEnv("CCAI_TEST_FIRST_NAME_2"); +var ln2 = MustEnv("CCAI_TEST_LAST_NAME_2"); +var fn3 = MustEnv("CCAI_TEST_FIRST_NAME_3"); +var ln3 = MustEnv("CCAI_TEST_LAST_NAME_3"); +var webhookUrl = MustEnv("WEBHOOK_URL"); + +// Use CCAI_BASE_URL if set (local dev), otherwise fall back to test environment +var client = new CCAIClient(new CCAIConfig +{ + ClientId = clientId, + ApiKey = apiKey, + UseTestEnvironment = Environment.GetEnvironmentVariable("CCAI_BASE_URL") is null +}); + +var pngPath = WriteTempPng(); + +Console.WriteLine("=============================================="); +Console.WriteLine(" CCAI .NET SDK Integration Tests"); +Console.WriteLine("=============================================="); + +// ── SMS Tests (1-6) ─────────────────────────────────────────────────────────── +Console.WriteLine("\n--- SMS ---"); + +// 01 — SMS.SendSingleAsync +await Run("01 SMS.SendSingleAsync", async () => +{ + await client.SMS.SendSingleAsync(fn1, ln1, phone1, "Hello from .NET SDK!", ".NET Test"); +}); + +// 02 — SMS.SendAsync (1 recipient) +await Run("02 SMS.SendAsync (1 recipient)", async () => +{ + await client.SMS.SendAsync( + [new Account { FirstName = fn1, LastName = ln1, Phone = phone1 }], + "Hello 1 recipient!", ".NET Test"); +}); + +// 03 — SMS.SendAsync (2 recipients) +await Run("03 SMS.SendAsync (2 recipients)", async () => +{ + await client.SMS.SendAsync( + [ + new Account { FirstName = fn1, LastName = ln1, Phone = phone1 }, + new Account { FirstName = fn2, LastName = ln2, Phone = phone2 }, + ], + "Hello 2 recipients!", ".NET Test"); +}); + +// 04 — SMS.SendAsync (3 recipients) +await Run("04 SMS.SendAsync (3 recipients)", async () => +{ + await client.SMS.SendAsync( + [ + new Account { FirstName = fn1, LastName = ln1, Phone = phone1 }, + new Account { FirstName = fn2, LastName = ln2, Phone = phone2 }, + new Account { FirstName = fn3, LastName = ln3, Phone = phone3 }, + ], + "Hello 3 recipients!", ".NET Test"); +}); + +// 05 — SMS.SendAsync with Data +await Run("05 SMS.SendAsync with Data", async () => +{ + await client.SMS.SendAsync( + [new Account { FirstName = fn1, LastName = ln1, Phone = phone1, Data = new() { ["city"] = "Miami", ["offer"] = "20% off" } }], + "Hello from ${city}! Claim your ${offer}.", ".NET Test Data"); +}); + +// 06 — SMS.SendAsync with CustomData +await Run("06 SMS.SendAsync with CustomData", async () => +{ + await client.SMS.SendAsync( + [new Account { FirstName = fn1, LastName = ln1, Phone = phone1, CustomData = "{\"trackingId\":\"abc123\"}" }], + "Hello with messageData!", ".NET Test MsgData"); +}); + +// ── MMS Tests (7-17) ────────────────────────────────────────────────────────── +Console.WriteLine("\n--- MMS ---"); + +SignedUrlResponse? signedUrlResp = null; +bool mmsDep = false; + +// 07 — MMS.GetSignedUploadUrlAsync +await Run("07 MMS.GetSignedUploadUrlAsync", async () => +{ + var resp = await client.MMS.GetSignedUploadUrlAsync("test_image.png", "image/png"); + if (string.IsNullOrEmpty(resp.SignedS3Url)) + { + mmsDep = true; + throw new Exception("SignedS3Url is empty"); + } + signedUrlResp = resp; +}); + +// 08 — MMS.UploadImageToSignedUrlAsync +await Run("08 MMS.UploadImageToSignedUrlAsync", async () => +{ + if (mmsDep || signedUrlResp == null) throw new Exception("dependency test 07 failed"); + var ok = await client.MMS.UploadImageToSignedUrlAsync(signedUrlResp.SignedS3Url, pngPath, "image/png"); + if (!ok) throw new Exception("upload returned false"); +}); + +// 09 — MMS.SendSingleAsync +await Run("09 MMS.SendSingleAsync", async () => +{ + if (mmsDep || signedUrlResp == null) throw new Exception("dependency test 07 failed"); + await client.MMS.SendSingleAsync(signedUrlResp.FileKey, fn1, ln1, phone1, "MMS single!", ".NET MMS Test"); +}); + +// 10 — MMS.SendAsync (1 recipient) +await Run("10 MMS.SendAsync (1 recipient)", async () => +{ + if (mmsDep || signedUrlResp == null) throw new Exception("dependency test 07 failed"); + await client.MMS.SendAsync(signedUrlResp.FileKey, + [new Account { FirstName = fn1, LastName = ln1, Phone = phone1 }], + "MMS 1 recipient!", ".NET MMS Test"); +}); + +// 11 — MMS.SendAsync (2 recipients) +await Run("11 MMS.SendAsync (2 recipients)", async () => +{ + if (mmsDep || signedUrlResp == null) throw new Exception("dependency test 07 failed"); + await client.MMS.SendAsync(signedUrlResp.FileKey, + [ + new Account { FirstName = fn1, LastName = ln1, Phone = phone1 }, + new Account { FirstName = fn2, LastName = ln2, Phone = phone2 }, + ], + "MMS 2 recipients!", ".NET MMS Test"); +}); + +// 12 — MMS.SendAsync (3 recipients) +await Run("12 MMS.SendAsync (3 recipients)", async () => +{ + if (mmsDep || signedUrlResp == null) throw new Exception("dependency test 07 failed"); + await client.MMS.SendAsync(signedUrlResp.FileKey, + [ + new Account { FirstName = fn1, LastName = ln1, Phone = phone1 }, + new Account { FirstName = fn2, LastName = ln2, Phone = phone2 }, + new Account { FirstName = fn3, LastName = ln3, Phone = phone3 }, + ], + "MMS 3 recipients!", ".NET MMS Test"); +}); + +// 13 — MMS.SendAsync with Data +await Run("13 MMS.SendAsync with Data", async () => +{ + if (mmsDep || signedUrlResp == null) throw new Exception("dependency test 07 failed"); + await client.MMS.SendAsync(signedUrlResp.FileKey, + [new Account { FirstName = fn1, LastName = ln1, Phone = phone1, Data = new() { ["product"] = "Widget" } }], + "Check out ${product}!", ".NET MMS Data"); +}); + +// 14 — MMS.SendAsync with CustomData +await Run("14 MMS.SendAsync with CustomData", async () => +{ + if (mmsDep || signedUrlResp == null) throw new Exception("dependency test 07 failed"); + await client.MMS.SendAsync(signedUrlResp.FileKey, + [new Account { FirstName = fn1, LastName = ln1, Phone = phone1, CustomData = "{\"campaignId\":\"mms-net-001\"}" }], + "MMS with messageData!", ".NET MMS MsgData"); +}); + +// 15 — MMS.CheckFileUploadedAsync +await Run("15 MMS.CheckFileUploadedAsync", async () => +{ + if (mmsDep || signedUrlResp == null) throw new Exception("dependency test 07 failed"); + await client.MMS.CheckFileUploadedAsync(signedUrlResp.FileKey); +}); + +// 16 — MMS.SendWithImageAsync (fresh upload) +await Run("16 MMS.SendWithImageAsync (fresh upload)", async () => +{ + if (mmsDep) throw new Exception("dependency test 07 failed"); + await client.MMS.SendWithImageAsync(pngPath, "image/png", + [new Account { FirstName = fn1, LastName = ln1, Phone = phone1 }], + "MMS with image!", ".NET MMS Image", + forceNewCampaign: true); +}); + +// 17 — MMS.SendWithImageAsync (cached) +await Run("17 MMS.SendWithImageAsync (cached)", async () => +{ + if (mmsDep) throw new Exception("dependency test 07 failed"); + await client.MMS.SendWithImageAsync(pngPath, "image/png", + [new Account { FirstName = fn1, LastName = ln1, Phone = phone1 }], + "MMS cached image!", ".NET MMS Cache", + forceNewCampaign: true); +}); + +// ── Email Tests (18-22) ─────────────────────────────────────────────────────── +Console.WriteLine("\n--- Email ---"); + +const string SenderEmail = "noreply@cloudcontactai.com"; +const string SenderName = "CCAI Test"; +const string ReplyEmail = "noreply@cloudcontactai.com"; + +// 18 — Email.SendSingleAsync +await Run("18 Email.SendSingleAsync", async () => +{ + await client.Email.SendSingleAsync( + fn1, ln1, email1, + ".NET SDK Test Email", "

Hello from .NET SDK!

", + senderEmail: SenderEmail, replyEmail: ReplyEmail, senderName: SenderName, + title: ".NET Email Test"); +}); + +// 19 — Email.SendCampaignAsync (1 recipient) +await Run("19 Email.SendCampaignAsync (1 recipient)", async () => +{ + await client.Email.SendCampaignAsync(new EmailCampaign + { + Subject = ".NET SDK Email 1", + Title = ".NET Email Test", + Message = "

Hello 1!

", + SenderEmail = SenderEmail, ReplyEmail = ReplyEmail, SenderName = SenderName, + Accounts = [new EmailAccount { FirstName = fn1, LastName = ln1, Email = email1 }] + }); +}); + +// 20 — Email.SendCampaignAsync (2 recipients) +await Run("20 Email.SendCampaignAsync (2 recipients)", async () => +{ + await client.Email.SendCampaignAsync(new EmailCampaign + { + Subject = ".NET SDK Email 2", + Title = ".NET Email Test", + Message = "

Hello 2!

", + SenderEmail = SenderEmail, ReplyEmail = ReplyEmail, SenderName = SenderName, + Accounts = + [ + new EmailAccount { FirstName = fn1, LastName = ln1, Email = email1 }, + new EmailAccount { FirstName = fn2, LastName = ln2, Email = email2 }, + ] + }); +}); + +// 21 — Email.SendCampaignAsync (3 recipients) +await Run("21 Email.SendCampaignAsync (3 recipients)", async () => +{ + await client.Email.SendCampaignAsync(new EmailCampaign + { + Subject = ".NET SDK Email 3", + Title = ".NET Email Test", + Message = "

Hello 3!

", + SenderEmail = SenderEmail, ReplyEmail = ReplyEmail, SenderName = SenderName, + Accounts = + [ + new EmailAccount { FirstName = fn1, LastName = ln1, Email = email1 }, + new EmailAccount { FirstName = fn2, LastName = ln2, Email = email2 }, + new EmailAccount { FirstName = fn3, LastName = ln3, Email = email3 }, + ] + }); +}); + +// 22 — Email.SendCampaignAsync (full campaign object) +await Run("22 Email.SendCampaignAsync (full campaign)", async () => +{ + await client.Email.SendCampaignAsync(new EmailCampaign + { + Subject = ".NET SDK Campaign Test", + Title = ".NET Email Campaign", + Message = "

Campaign email from .NET SDK!

", + SenderEmail = SenderEmail, ReplyEmail = ReplyEmail, SenderName = SenderName, + Accounts = + [ + new EmailAccount { FirstName = fn1, LastName = ln1, Email = email1 }, + new EmailAccount { FirstName = fn2, LastName = ln2, Email = email2 }, + ] + }); +}); + +// ── Webhook Tests (23-29) ───────────────────────────────────────────────────── +Console.WriteLine("\n--- Webhook ---"); + +const string WebhookSecret = "test-webhook-secret-dotnet"; +int registeredWebhookId = 0; + +// 23 — Webhook.RegisterAsync +await Run("23 Webhook.RegisterAsync", async () => +{ + var resp = await client.Webhook.RegisterAsync(new WebhookConfig + { + Url = webhookUrl, + Secret = WebhookSecret + }); + + if (resp.Id == 0) throw new Exception("webhook ID is 0 after register"); + registeredWebhookId = resp.Id; +}); + +// 24 — Webhook.ListAsync +await Run("24 Webhook.ListAsync", async () => +{ + var hooks = await client.Webhook.ListAsync(); + if (hooks == null || hooks.Count == 0) throw new Exception("expected at least one webhook, got 0"); +}); + +// 25 — Webhook.UpdateAsync +await Run("25 Webhook.UpdateAsync", async () => +{ + if (registeredWebhookId == 0) throw new Exception("no webhook ID from test 23"); + await client.Webhook.UpdateAsync(registeredWebhookId, new WebhookConfig + { + Url = webhookUrl + "?updated=1", + Secret = "updated-secret-dotnet" + }); +}); + +// 26 — Webhook.VerifySignature (valid) +await Run("26 Webhook.VerifySignature (valid)", async () => +{ + await Task.CompletedTask; + const string eventHash = "abc123eventHash"; + var sig = HmacSha256Base64(WebhookSecret, $"{clientId}:{eventHash}"); + var ok = client.Webhook.VerifySignature(sig, clientId, eventHash, WebhookSecret); + if (!ok) throw new Exception("expected valid signature to return true"); +}); + +// 27 — Webhook.VerifySignature (invalid) +await Run("27 Webhook.VerifySignature (invalid)", async () => +{ + await Task.CompletedTask; + var ok = client.Webhook.VerifySignature("invalidsig==", clientId, "somehash", WebhookSecret); + if (ok) throw new Exception("expected invalid signature to return false"); +}); + +// 28 — Webhook.ParseCloudContactEvent +await Run("28 Webhook.ParseCloudContactEvent", async () => +{ + await Task.CompletedTask; + var payload = "{\"eventType\":\"message.sent\",\"data\":{\"To\":\"+15005550001\",\"Message\":\"test\",\"MessageStatus\":\"DELIVERED\",\"SmsSid\":1,\"CampaignId\":0,\"CampaignTitle\":\"\",\"CustomData\":\"\",\"ClientExternalId\":\"\"}}"; + var evt = client.Webhook.ParseCloudContactEvent(payload); + if (string.IsNullOrEmpty(evt.EventType)) throw new Exception("EventType is empty after ParseCloudContactEvent"); +}); + +// 29 — Webhook.DeleteAsync +await Run("29 Webhook.DeleteAsync", async () => +{ + if (registeredWebhookId == 0) throw new Exception("no webhook ID from test 23"); + await client.Webhook.DeleteAsync(registeredWebhookId); +}); + +// ── Contact Tests (30-31) ───────────────────────────────────────────────────── +Console.WriteLine("\n--- Contact ---"); + +// 30 — Contact.SetDoNotTextAsync(true) +await Run("30 Contact.SetDoNotTextAsync(true)", async () => +{ + await client.Contact.SetDoNotTextAsync(true, phone: phone1); +}); + +// 31 — Contact.SetDoNotTextAsync(false) +await Run("31 Contact.SetDoNotTextAsync(false)", async () => +{ + await client.Contact.SetDoNotTextAsync(false, phone: phone1); +}); + +// ── Brand Tests (32-36) ─────────────────────────────────────────────────────── +Console.WriteLine("\n--- Brands ---"); + +long brandId = 0; + +// 32 — Brand.CreateAsync +await Run("32 Brand.CreateAsync", async () => +{ + var resp = await client.Brands.CreateAsync(new BrandRequest + { + LegalCompanyName = "Test Company LLC", + EntityType = "PRIVATE_PROFIT", + TaxId = "123456789", + TaxIdCountry = "US", + Country = "US", + VerticalType = "TECHNOLOGY", + WebsiteUrl = "https://example.com", + Street = "123 Main St", + City = "Miami", + State = "FL", + PostalCode = "33101", + ContactFirstName = fn1, + ContactLastName = ln1, + ContactEmail = email1, + ContactPhone = phone1 + }); + if (resp.Id == 0) throw new Exception("Invalid brand id"); + brandId = resp.Id; +}); + +// 33 — Brand.GetAsync +await Run("33 Brand.GetAsync", async () => +{ + if (brandId == 0) throw new Exception("dependency test 32 failed"); + var resp = await client.Brands.GetAsync(brandId); + if (resp.Id != brandId) throw new Exception("Brand id mismatch"); +}); + +// 34 — Brand.ListAsync +await Run("34 Brand.ListAsync", async () => +{ + var list = await client.Brands.ListAsync(); + if (list == null) throw new Exception("Null response"); +}); + +// 35 — Brand.UpdateAsync +await Run("35 Brand.UpdateAsync", async () => +{ + if (brandId == 0) throw new Exception("dependency test 32 failed"); + var resp = await client.Brands.UpdateAsync(brandId, new BrandRequest { City = "Orlando" }); + if (resp.Id != brandId) throw new Exception("Brand id mismatch after update"); +}); + +// 36 — Brand.DeleteAsync +await Run("36 Brand.DeleteAsync", async () => +{ + if (brandId == 0) throw new Exception("dependency test 32 failed"); + await client.Brands.DeleteAsync(brandId); +}); + +// ── Campaign Tests (37-42) ──────────────────────────────────────────────────── +Console.WriteLine("\n--- Campaigns ---"); + +long campaignBrandId = 0; +long campaignId = 0; + +// 37 — Campaign setup: create brand +await Run("37 Campaign setup — Brand.CreateAsync", async () => +{ + var resp = await client.Brands.CreateAsync(new BrandRequest + { + LegalCompanyName = "Campaign Test LLC", + EntityType = "PRIVATE_PROFIT", + TaxId = "987654321", + TaxIdCountry = "US", + Country = "US", + VerticalType = "TECHNOLOGY", + WebsiteUrl = "https://example.com", + Street = "456 Test Ave", + City = "Miami", + State = "FL", + PostalCode = "33101", + ContactFirstName = fn1, + ContactLastName = ln1, + ContactEmail = email1, + ContactPhone = phone1 + }); + if (resp.Id == 0) throw new Exception("Invalid brand id"); + campaignBrandId = resp.Id; +}); + +// 38 — Campaign.CreateAsync +await Run("38 Campaign.CreateAsync", async () => +{ + if (campaignBrandId == 0) throw new Exception("dependency test 37 failed"); + var resp = await client.Campaigns.CreateAsync(new CampaignRequest + { + BrandId = campaignBrandId, + UseCase = "MARKETING", + Description = "Integration test campaign for automated testing", + MessageFlow = "Customers opt-in via website form at https://example.com/sms-signup", + HasEmbeddedLinks = false, + HasEmbeddedPhone = false, + IsAgeGated = false, + IsDirectLending = false, + OptInKeywords = ["START", "YES"], + OptInMessage = "You have opted in to receive messages. Reply STOP to unsubscribe.", + OptInProofUrl = "https://example.com/opt-in-proof", + HelpKeywords = ["HELP", "INFO"], + HelpMessage = "For help reply HELP or call 1-800-555-0000.", + OptOutKeywords = ["STOP", "END"], + OptOutMessage = "You have been unsubscribed. Reply START to opt back in. STOP", + SampleMessages = + [ + "Hello ${firstName}, this is a test message. Reply STOP to unsubscribe.", + "Reminder: your appointment is tomorrow. Reply HELP for assistance." + ] + }); + if (resp.Id == 0) throw new Exception("Invalid campaign id"); + campaignId = resp.Id; +}); + +// 39 — Campaign.GetAsync +await Run("39 Campaign.GetAsync", async () => +{ + if (campaignId == 0) throw new Exception("dependency test 38 failed"); + var resp = await client.Campaigns.GetAsync(campaignId); + if (resp.Id != campaignId) throw new Exception("Campaign id mismatch"); +}); + +// 40 — Campaign.ListAsync +await Run("40 Campaign.ListAsync", async () => +{ + var list = await client.Campaigns.ListAsync(); + if (list == null) throw new Exception("Null response"); +}); + +// 41 — Campaign.UpdateAsync +await Run("41 Campaign.UpdateAsync", async () => +{ + if (campaignId == 0) throw new Exception("dependency test 38 failed"); + var resp = await client.Campaigns.UpdateAsync(campaignId, new CampaignRequest + { + Description = "Updated integration test campaign description" + }); + if (resp.Id != campaignId) throw new Exception("Campaign id mismatch after update"); +}); + +// 42 — Campaign.DeleteAsync + cleanup brand +await Run("42 Campaign.DeleteAsync", async () => +{ + if (campaignId == 0) throw new Exception("dependency test 38 failed"); + await client.Campaigns.DeleteAsync(campaignId); + if (campaignBrandId != 0) await client.Brands.DeleteAsync(campaignBrandId); +}); + +// ── Contact Validator ────────────────────────────────────────────────────────── + +// 43 — ContactValidator.ValidateEmailAsync +await Run("43 ContactValidator.ValidateEmailAsync", async () => +{ + var resp = await client.ContactValidator.ValidateEmailAsync(email1); + if (string.IsNullOrEmpty(resp.Status)) throw new Exception("status is empty"); +}); + +// 44 — ContactValidator.ValidateEmailsAsync +await Run("44 ContactValidator.ValidateEmailsAsync", async () => +{ + var resp = await client.ContactValidator.ValidateEmailsAsync(new[] { email1, email2 }); + if (resp.Summary.Total != 2) throw new Exception($"expected summary.total=2, got {resp.Summary.Total}"); +}); + +// 45 — ContactValidator.ValidatePhoneAsync +await Run("45 ContactValidator.ValidatePhoneAsync", async () => +{ + var resp = await client.ContactValidator.ValidatePhoneAsync(phone1); + if (string.IsNullOrEmpty(resp.Status)) throw new Exception("status is empty"); +}); + +// 46 — ContactValidator.ValidatePhonesAsync +await Run("46 ContactValidator.ValidatePhonesAsync", async () => +{ + var resp = await client.ContactValidator.ValidatePhonesAsync(new[] + { + new PhoneInput { Phone = phone1 }, + new PhoneInput { Phone = phone2 } + }); + if (resp.Summary.Total != 2) throw new Exception($"expected summary.total=2, got {resp.Summary.Total}"); +}); + +// ── Cleanup & Results ────────────────────────────────────────────────────────── +client.Dispose(); +if (File.Exists(pngPath)) File.Delete(pngPath); + +Console.WriteLine("\n=============================================="); +Console.WriteLine($" RESULTS: {passed} passed, {failed} failed"); +Console.WriteLine("=============================================="); + +var summary = JsonSerializer.Serialize(new { sdk = "dotnet", passed, failed, total = passed + failed }); +Console.WriteLine($"\nSUMMARY_JSON: {summary}"); + +Environment.Exit(failed > 0 ? 1 : 0); diff --git a/src/CCAI.NET/Brands/BrandModels.cs b/src/CCAI.NET/Brands/BrandModels.cs new file mode 100644 index 0000000..22257b2 --- /dev/null +++ b/src/CCAI.NET/Brands/BrandModels.cs @@ -0,0 +1,138 @@ +// Copyright (c) 2025 CloudContactAI LLC +// Licensed under the MIT License. See LICENSE in the project root for license information. + +using System.Text.Json.Serialization; + +namespace CCAI.NET.Brands; + +public class BrandRequest +{ + [JsonPropertyName("legalCompanyName")] + public string? LegalCompanyName { get; set; } + + [JsonPropertyName("dba")] + public string? Dba { get; set; } + + [JsonPropertyName("entityType")] + public string? EntityType { get; set; } + + [JsonPropertyName("taxId")] + public string? TaxId { get; set; } + + [JsonPropertyName("taxIdCountry")] + public string? TaxIdCountry { get; set; } + + [JsonPropertyName("country")] + public string? Country { get; set; } + + [JsonPropertyName("verticalType")] + public string? VerticalType { get; set; } + + [JsonPropertyName("websiteUrl")] + public string? WebsiteUrl { get; set; } + + [JsonPropertyName("stockSymbol")] + public string? StockSymbol { get; set; } + + [JsonPropertyName("stockExchange")] + public string? StockExchange { get; set; } + + [JsonPropertyName("street")] + public string? Street { get; set; } + + [JsonPropertyName("city")] + public string? City { get; set; } + + [JsonPropertyName("state")] + public string? State { get; set; } + + [JsonPropertyName("postalCode")] + public string? PostalCode { get; set; } + + [JsonPropertyName("contactFirstName")] + public string? ContactFirstName { get; set; } + + [JsonPropertyName("contactLastName")] + public string? ContactLastName { get; set; } + + [JsonPropertyName("contactEmail")] + public string? ContactEmail { get; set; } + + [JsonPropertyName("contactPhone")] + public string? ContactPhone { get; set; } + + [JsonPropertyName("websiteMatch")] + public bool WebsiteMatch { get; set; } = false; +} + +public class BrandResponse +{ + [JsonPropertyName("id")] + public long Id { get; set; } + + [JsonPropertyName("accountId")] + public long AccountId { get; set; } + + [JsonPropertyName("legalCompanyName")] + public string LegalCompanyName { get; set; } = ""; + + [JsonPropertyName("dba")] + public string? Dba { get; set; } + + [JsonPropertyName("entityType")] + public string EntityType { get; set; } = ""; + + [JsonPropertyName("taxId")] + public string TaxId { get; set; } = ""; + + [JsonPropertyName("taxIdCountry")] + public string TaxIdCountry { get; set; } = ""; + + [JsonPropertyName("country")] + public string Country { get; set; } = ""; + + [JsonPropertyName("verticalType")] + public string VerticalType { get; set; } = ""; + + [JsonPropertyName("websiteUrl")] + public string WebsiteUrl { get; set; } = ""; + + [JsonPropertyName("stockSymbol")] + public string? StockSymbol { get; set; } + + [JsonPropertyName("stockExchange")] + public string? StockExchange { get; set; } + + [JsonPropertyName("street")] + public string Street { get; set; } = ""; + + [JsonPropertyName("city")] + public string City { get; set; } = ""; + + [JsonPropertyName("state")] + public string State { get; set; } = ""; + + [JsonPropertyName("postalCode")] + public string PostalCode { get; set; } = ""; + + [JsonPropertyName("contactFirstName")] + public string ContactFirstName { get; set; } = ""; + + [JsonPropertyName("contactLastName")] + public string ContactLastName { get; set; } = ""; + + [JsonPropertyName("contactEmail")] + public string ContactEmail { get; set; } = ""; + + [JsonPropertyName("contactPhone")] + public string ContactPhone { get; set; } = ""; + + [JsonPropertyName("websiteMatchScore")] + public int? WebsiteMatchScore { get; set; } + + [JsonPropertyName("createdAt")] + public string CreatedAt { get; set; } = ""; + + [JsonPropertyName("updatedAt")] + public string UpdatedAt { get; set; } = ""; +} diff --git a/src/CCAI.NET/Brands/BrandService.cs b/src/CCAI.NET/Brands/BrandService.cs new file mode 100644 index 0000000..b0f1de4 --- /dev/null +++ b/src/CCAI.NET/Brands/BrandService.cs @@ -0,0 +1,119 @@ +// Copyright (c) 2025 CloudContactAI LLC +// Licensed under the MIT License. See LICENSE in the project root for license information. + +using System.Text.RegularExpressions; + +namespace CCAI.NET.Brands; + +/// +/// Brand service for managing brand registrations +/// +public class BrandService +{ + private readonly CCAIClient _client; + + private static readonly HashSet EntityTypes = new() + { + "PRIVATE_PROFIT", "PUBLIC_PROFIT", "NON_PROFIT", "GOVERNMENT", "SOLE_PROPRIETOR" + }; + + private static readonly HashSet VerticalTypes = new() + { + "AUTOMOTIVE", "AGRICULTURE", "BANKING", "COMMUNICATION", "CONSTRUCTION", "EDUCATION", + "ENERGY", "ENTERTAINMENT", "GOVERNMENT", "HEALTHCARE", "HOSPITALITY", "INSURANCE", + "LEGAL", "MANUFACTURING", "NON_PROFIT", "PROFESSIONAL", "REAL_ESTATE", "RETAIL", + "TECHNOLOGY", "TRANSPORTATION" + }; + + private static readonly HashSet TaxIdCountries = new() { "US", "CA", "GB", "AU" }; + private static readonly HashSet StockExchanges = new() { "NASDAQ", "NYSE", "AMEX", "TSX", "LON", "JPX", "HKEX", "OTHER" }; + + public BrandService(CCAIClient client) + { + _client = client; + } + + public async Task CreateAsync(BrandRequest data, CancellationToken cancellationToken = default) + { + Validate(data, isCreate: true); + return await _client.CustomRequestAsync(HttpMethod.Post, "/v1/brands", data, _client.GetComplianceBaseUrl(), cancellationToken); + } + + public BrandResponse Create(BrandRequest data) => CreateAsync(data).GetAwaiter().GetResult(); + + public async Task GetAsync(long id, CancellationToken cancellationToken = default) + { + return await _client.CustomRequestAsync(HttpMethod.Get, $"/v1/brands/{id}", null, _client.GetComplianceBaseUrl(), cancellationToken); + } + + public BrandResponse Get(long id) => GetAsync(id).GetAwaiter().GetResult(); + + public async Task ListAsync(CancellationToken cancellationToken = default) + { + return await _client.CustomRequestAsync(HttpMethod.Get, "/v1/brands", null, _client.GetComplianceBaseUrl(), cancellationToken); + } + + public BrandResponse[] List() => ListAsync().GetAwaiter().GetResult(); + + public async Task UpdateAsync(long id, BrandRequest data, CancellationToken cancellationToken = default) + { + Validate(data, isCreate: false); + return await _client.CustomRequestAsync(HttpMethod.Patch, $"/v1/brands/{id}", data, _client.GetComplianceBaseUrl(), cancellationToken); + } + + public BrandResponse Update(long id, BrandRequest data) => UpdateAsync(id, data).GetAwaiter().GetResult(); + + public async Task DeleteAsync(long id, CancellationToken cancellationToken = default) + { + await _client.CustomRequestWithoutResponseAsync(HttpMethod.Delete, $"/v1/brands/{id}", null, _client.GetComplianceBaseUrl(), cancellationToken); + } + + public void Delete(long id) => DeleteAsync(id).GetAwaiter().GetResult(); + + private static void Validate(BrandRequest data, bool isCreate) + { + var errors = new List(); + + if (isCreate) + { + if (string.IsNullOrWhiteSpace(data.LegalCompanyName)) errors.Add("legalCompanyName is required"); + if (string.IsNullOrWhiteSpace(data.EntityType)) errors.Add("entityType is required"); + if (string.IsNullOrWhiteSpace(data.TaxId)) errors.Add("taxId is required"); + if (string.IsNullOrWhiteSpace(data.TaxIdCountry)) errors.Add("taxIdCountry is required"); + if (string.IsNullOrWhiteSpace(data.Country)) errors.Add("country is required"); + if (string.IsNullOrWhiteSpace(data.VerticalType)) errors.Add("verticalType is required"); + if (string.IsNullOrWhiteSpace(data.WebsiteUrl)) errors.Add("websiteUrl is required"); + if (string.IsNullOrWhiteSpace(data.Street)) errors.Add("street is required"); + if (string.IsNullOrWhiteSpace(data.City)) errors.Add("city is required"); + if (string.IsNullOrWhiteSpace(data.State)) errors.Add("state is required"); + if (string.IsNullOrWhiteSpace(data.PostalCode)) errors.Add("postalCode is required"); + if (string.IsNullOrWhiteSpace(data.ContactFirstName)) errors.Add("contactFirstName is required"); + if (string.IsNullOrWhiteSpace(data.ContactLastName)) errors.Add("contactLastName is required"); + if (string.IsNullOrWhiteSpace(data.ContactEmail)) errors.Add("contactEmail is required"); + if (string.IsNullOrWhiteSpace(data.ContactPhone)) errors.Add("contactPhone is required"); + } + + if (data.EntityType != null && !EntityTypes.Contains(data.EntityType)) errors.Add("Invalid entity type"); + if (data.VerticalType != null && !VerticalTypes.Contains(data.VerticalType)) errors.Add("Invalid vertical type"); + if (data.TaxIdCountry != null && !TaxIdCountries.Contains(data.TaxIdCountry)) errors.Add("Invalid tax ID country"); + if (data.StockExchange != null && !StockExchanges.Contains(data.StockExchange)) errors.Add("Invalid stock exchange"); + + if (data.WebsiteUrl != null && !data.WebsiteUrl.StartsWith("http://") && !data.WebsiteUrl.StartsWith("https://")) + errors.Add("Website URL must start with http:// or https://"); + + if (data.ContactEmail != null && !Regex.IsMatch(data.ContactEmail, @"^[^@\s]+@[^@\s]+\.[^@\s]+$")) + errors.Add("Invalid email format"); + + if (data.TaxId != null && data.TaxIdCountry is "US" or "CA" && !Regex.IsMatch(data.TaxId, @"^\d{9}$")) + errors.Add($"Tax ID must be exactly 9 digits for {data.TaxIdCountry}"); + + if (data.EntityType == "PUBLIC_PROFIT") + { + if (string.IsNullOrWhiteSpace(data.StockSymbol)) errors.Add("Stock symbol is required for PUBLIC_PROFIT entities"); + if (string.IsNullOrWhiteSpace(data.StockExchange)) errors.Add("Stock exchange is required for PUBLIC_PROFIT entities"); + } + + if (errors.Count > 0) + throw new ArgumentException($"Validation failed: {string.Join(", ", errors)}"); + } +} diff --git a/src/CCAI.NET/CCAI.NET.csproj b/src/CCAI.NET/CCAI.NET.csproj index 479c99f..1dc26a5 100644 --- a/src/CCAI.NET/CCAI.NET.csproj +++ b/src/CCAI.NET/CCAI.NET.csproj @@ -6,7 +6,7 @@ enable latest CloudContactAI.CCAI.NET - 1.4.5 + 1.5.0 CloudContactAI LLC CloudContactAI LLC C# client for CloudContactAI API with SMS, MMS, Email, and Webhook support. Enhanced webhook support with new CloudContact event format, contact.unsubscribed events, environment variable configuration, and comprehensive examples. @@ -15,7 +15,7 @@ true https://github.com/CloudContactAI/CCAI.NET true - $([System.IO.File]::ReadAllText("$(MSBuildProjectDirectory)/../../RELEASE-NOTES-v1.4.5.md")) + $([System.IO.File]::ReadAllText("$(MSBuildProjectDirectory)/../../RELEASE-NOTES-v1.5.0.md")) https://github.com/CloudContactAI/CCAI.NET git README.md diff --git a/src/CCAI.NET/CCAIClient.cs b/src/CCAI.NET/CCAIClient.cs index 5390a89..aee1400 100644 --- a/src/CCAI.NET/CCAIClient.cs +++ b/src/CCAI.NET/CCAIClient.cs @@ -6,7 +6,10 @@ using System.Text; using System.Text.Json; using System.Text.Json.Serialization; +using CCAI.NET.Brands; +using CCAI.NET.Campaigns; using CCAI.NET.Contact; +using CCAI.NET.ContactValidator; using CCAI.NET.Email; using CCAI.NET.SMS; using CCAI.NET.Webhook; @@ -48,6 +51,21 @@ public interface ICCAIClient : IDisposable /// IContactService Contact { get; } + /// + /// Brand service for managing brand registrations + /// + BrandService Brands { get; } + + /// + /// Campaign service for managing 10DLC campaign registrations + /// + CampaignService Campaigns { get; } + + /// + /// Contact validator service for validating email and phone contacts + /// + IContactValidatorService ContactValidator { get; } + /// /// Get the client ID /// @@ -78,6 +96,11 @@ public interface ICCAIClient : IDisposable /// string GetFilesBaseUrl(); + /// + /// Get the compliance base URL + /// + string GetComplianceBaseUrl(); + /// /// Make an authenticated API request to the CCAI API /// @@ -99,6 +122,17 @@ Task CustomRequestAsync( CancellationToken cancellationToken = default, Dictionary? headers = null); + /// + /// Make an authenticated API request to a custom API endpoint without expecting a JSON response + /// + Task CustomRequestWithoutResponseAsync( + HttpMethod method, + string endpoint, + object? data = null, + string? baseUrl = null, + CancellationToken cancellationToken = default, + Dictionary? headers = null); + /// /// Make an authenticated API request to the CCAI API without expecting a JSON response /// @@ -144,6 +178,11 @@ public record CCAIConfig /// Base URL for the Files API /// public string FilesBaseUrl { get; init; } = Environment.GetEnvironmentVariable("CCAI_FILES_BASE_URL") ?? "https://files.cloudcontactai.com"; + + /// + /// Base URL for the Compliance API + /// + public string ComplianceBaseUrl { get; init; } = Environment.GetEnvironmentVariable("CCAI_COMPLIANCE_BASE_URL") ?? "https://compliance.cloudcontactai.com/api"; /// /// Whether to use test environment URLs @@ -189,6 +228,16 @@ public string GetFilesBaseUrl() ? Environment.GetEnvironmentVariable("CCAI_TEST_FILES_BASE_URL") ?? "https://files-test-cloudcontactai.allcode.com" : FilesBaseUrl; } + + /// + /// Get the appropriate compliance base URL based on environment + /// + public string GetComplianceBaseUrl() + { + return UseTestEnvironment + ? Environment.GetEnvironmentVariable("CCAI_TEST_COMPLIANCE_BASE_URL") ?? "https://compliance-test-cloudcontactai.allcode.com/api" + : ComplianceBaseUrl; + } } /// @@ -231,6 +280,21 @@ public class CCAIClient : ICCAIClient /// public IContactService Contact { get; } + /// + /// Brand service for managing brand registrations + /// + public BrandService Brands { get; } + + /// + /// Campaign service for managing 10DLC campaign registrations + /// + public CampaignService Campaigns { get; } + + /// + /// Contact validator service for validating email and phone contacts + /// + public IContactValidatorService ContactValidator { get; } + /// /// Create a new CCAI client instance /// @@ -278,31 +342,39 @@ public CCAIClient(CCAIConfig config, HttpClient? httpClient = null) Webhook = new WebhookService(this); Phone = new PhoneService(this); Contact = new ContactService(this); + Brands = new BrandService(this); + Campaigns = new CampaignService(this); + ContactValidator = new ContactValidatorService(this); } /// /// Get the client ID /// + /// Client ID public string GetClientId() => _config.ClientId; /// /// Get the API key /// + /// API key public string GetApiKey() => _config.ApiKey; /// /// Get the base URL /// + /// Base URL public string GetBaseUrl() => _config.GetBaseUrl(); - + /// /// Get the email base URL /// + /// Email base URL public string GetEmailBaseUrl() => _config.GetEmailBaseUrl(); - + /// /// Get the auth base URL /// + /// Auth base URL public string GetAuthBaseUrl() => _config.GetAuthBaseUrl(); /// @@ -310,9 +382,22 @@ public CCAIClient(CCAIConfig config, HttpClient? httpClient = null) /// public string GetFilesBaseUrl() => _config.GetFilesBaseUrl(); + /// + /// Get the compliance base URL + /// + public string GetComplianceBaseUrl() => _config.GetComplianceBaseUrl(); + /// /// Make an authenticated API request to the CCAI API /// + /// HTTP method + /// API endpoint + /// Request data + /// Cancellation token + /// Additional headers + /// Response type + /// API response + /// If the API returns an error public async Task RequestAsync( HttpMethod method, string endpoint, @@ -326,6 +411,15 @@ public async Task RequestAsync( /// /// Make an authenticated API request to a custom API endpoint /// + /// HTTP method + /// API endpoint + /// Request data + /// Custom base URL for the API + /// Cancellation token + /// Additional headers + /// Response type + /// API response + /// If the API returns an error public async Task CustomRequestAsync( HttpMethod method, string endpoint, @@ -335,15 +429,15 @@ public async Task CustomRequestAsync( Dictionary? headers = null) { var url = $"{baseUrl ?? _config.GetBaseUrl()}{endpoint}"; - + using var request = new HttpRequestMessage(method, url); - + if (data != null) { var json = JsonSerializer.Serialize(data, _jsonOptions); request.Content = new StringContent(json, Encoding.UTF8, "application/json"); } - + // Add additional headers if provided if (headers != null) { @@ -352,20 +446,20 @@ public async Task CustomRequestAsync( request.Headers.Add(key, value); } } - + using var response = await _httpClient.SendAsync(request, cancellationToken); - + // Throw an exception for HTTP errors response.EnsureSuccessStatusCode(); - + // Parse the response as JSON var result = await response.Content.ReadFromJsonAsync(_jsonOptions, cancellationToken); - + if (result == null) { throw new InvalidOperationException("Failed to deserialize response"); } - + return result; } @@ -405,16 +499,37 @@ public async Task RequestWithoutResponseAsync( CancellationToken cancellationToken = default, Dictionary? headers = null) { - var url = $"{_config.GetBaseUrl()}{endpoint}"; - + await CustomRequestWithoutResponseAsync(method, endpoint, data, _config.GetBaseUrl(), cancellationToken, headers); + } + + /// + /// Make an authenticated API request to a custom API endpoint without expecting a JSON response + /// + /// HTTP method + /// API endpoint + /// Request data + /// Custom base URL for the API + /// Cancellation token + /// Additional headers + /// If the API returns an error + public async Task CustomRequestWithoutResponseAsync( + HttpMethod method, + string endpoint, + object? data = null, + string? baseUrl = null, + CancellationToken cancellationToken = default, + Dictionary? headers = null) + { + var url = $"{baseUrl ?? _config.GetBaseUrl()}{endpoint}"; + using var request = new HttpRequestMessage(method, url); - + if (data != null) { var json = JsonSerializer.Serialize(data, _jsonOptions); request.Content = new StringContent(json, Encoding.UTF8, "application/json"); } - + if (headers != null) { foreach (var (key, value) in headers) @@ -422,7 +537,7 @@ public async Task RequestWithoutResponseAsync( request.Headers.Add(key, value); } } - + using var response = await _httpClient.SendAsync(request, cancellationToken); response.EnsureSuccessStatusCode(); } diff --git a/src/CCAI.NET/Campaigns/CampaignModels.cs b/src/CCAI.NET/Campaigns/CampaignModels.cs new file mode 100644 index 0000000..e22613d --- /dev/null +++ b/src/CCAI.NET/Campaigns/CampaignModels.cs @@ -0,0 +1,141 @@ +// Copyright (c) 2025 CloudContactAI LLC +// Licensed under the MIT License. See LICENSE in the project root for license information. + +using System.Text.Json.Serialization; + +namespace CCAI.NET.Campaigns; + +public class CampaignRequest +{ + [JsonPropertyName("brandId")] + public long? BrandId { get; set; } + + [JsonPropertyName("useCase")] + public string? UseCase { get; set; } + + [JsonPropertyName("subUseCases")] + public List? SubUseCases { get; set; } + + [JsonPropertyName("description")] + public string? Description { get; set; } + + [JsonPropertyName("messageFlow")] + public string? MessageFlow { get; set; } + + [JsonPropertyName("termsLink")] + public string? TermsLink { get; set; } + + [JsonPropertyName("privacyLink")] + public string? PrivacyLink { get; set; } + + [JsonPropertyName("hasEmbeddedLinks")] + public bool? HasEmbeddedLinks { get; set; } + + [JsonPropertyName("hasEmbeddedPhone")] + public bool? HasEmbeddedPhone { get; set; } + + [JsonPropertyName("isAgeGated")] + public bool? IsAgeGated { get; set; } + + [JsonPropertyName("isDirectLending")] + public bool? IsDirectLending { get; set; } + + [JsonPropertyName("optInKeywords")] + public List? OptInKeywords { get; set; } + + [JsonPropertyName("optInMessage")] + public string? OptInMessage { get; set; } + + [JsonPropertyName("optInProofUrl")] + public string? OptInProofUrl { get; set; } + + [JsonPropertyName("helpKeywords")] + public List? HelpKeywords { get; set; } + + [JsonPropertyName("helpMessage")] + public string? HelpMessage { get; set; } + + [JsonPropertyName("optOutKeywords")] + public List? OptOutKeywords { get; set; } + + [JsonPropertyName("optOutMessage")] + public string? OptOutMessage { get; set; } + + [JsonPropertyName("sampleMessages")] + public List? SampleMessages { get; set; } +} + +public class CampaignResponse +{ + [JsonPropertyName("id")] + public long Id { get; set; } + + [JsonPropertyName("accountId")] + public long AccountId { get; set; } + + [JsonPropertyName("brandId")] + public long BrandId { get; set; } + + [JsonPropertyName("useCase")] + public string UseCase { get; set; } = ""; + + [JsonPropertyName("subUseCases")] + public List SubUseCases { get; set; } = new(); + + [JsonPropertyName("description")] + public string Description { get; set; } = ""; + + [JsonPropertyName("messageFlow")] + public string MessageFlow { get; set; } = ""; + + [JsonPropertyName("termsLink")] + public string? TermsLink { get; set; } + + [JsonPropertyName("privacyLink")] + public string? PrivacyLink { get; set; } + + [JsonPropertyName("hasEmbeddedLinks")] + public bool HasEmbeddedLinks { get; set; } + + [JsonPropertyName("hasEmbeddedPhone")] + public bool HasEmbeddedPhone { get; set; } + + [JsonPropertyName("isAgeGated")] + public bool IsAgeGated { get; set; } + + [JsonPropertyName("isDirectLending")] + public bool IsDirectLending { get; set; } + + [JsonPropertyName("optInKeywords")] + public List OptInKeywords { get; set; } = new(); + + [JsonPropertyName("optInMessage")] + public string OptInMessage { get; set; } = ""; + + [JsonPropertyName("optInProofUrl")] + public string OptInProofUrl { get; set; } = ""; + + [JsonPropertyName("helpKeywords")] + public List HelpKeywords { get; set; } = new(); + + [JsonPropertyName("helpMessage")] + public string HelpMessage { get; set; } = ""; + + [JsonPropertyName("optOutKeywords")] + public List OptOutKeywords { get; set; } = new(); + + [JsonPropertyName("optOutMessage")] + public string OptOutMessage { get; set; } = ""; + + [JsonPropertyName("sampleMessages")] + public List SampleMessages { get; set; } = new(); + + [JsonPropertyName("monthlyFee")] + public decimal MonthlyFee { get; set; } + + [JsonPropertyName("createdAt")] + public string CreatedAt { get; set; } = ""; + + [JsonPropertyName("updatedAt")] + public string UpdatedAt { get; set; } = ""; +} diff --git a/src/CCAI.NET/Campaigns/CampaignService.cs b/src/CCAI.NET/Campaigns/CampaignService.cs new file mode 100644 index 0000000..a6b6a2a --- /dev/null +++ b/src/CCAI.NET/Campaigns/CampaignService.cs @@ -0,0 +1,163 @@ +// Copyright (c) 2025 CloudContactAI LLC +// Licensed under the MIT License. See LICENSE in the project root for license information. + +namespace CCAI.NET.Campaigns; + +/// +/// Campaign service for managing 10DLC campaign registrations +/// +public class CampaignService +{ + private readonly CCAIClient _client; + + private static readonly HashSet UseCases = new() + { + "TWO_FACTOR_AUTHENTICATION", "ACCOUNT_NOTIFICATION", "CUSTOMER_CARE", "DELIVERY_NOTIFICATION", + "FRAUD_ALERT", "HIGHER_EDUCATION", "LOW_VOLUME_MIXED", "MARKETING", "MIXED", + "POLLING_VOTING", "PUBLIC_SERVICE_ANNOUNCEMENT", "SECURITY_ALERT" + }; + + private static readonly HashSet SubUseCases = new() + { + "TWO_FACTOR_AUTHENTICATION", "ACCOUNT_NOTIFICATION", "CUSTOMER_CARE", "DELIVERY_NOTIFICATION", + "FRAUD_ALERT", "MARKETING", "POLLING_VOTING" + }; + + private static readonly HashSet MixedUseCases = new() { "MIXED", "LOW_VOLUME_MIXED" }; + + public CampaignService(CCAIClient client) + { + _client = client; + } + + public async Task CreateAsync(CampaignRequest data, CancellationToken cancellationToken = default) + { + Validate(data, isCreate: true); + return await _client.CustomRequestAsync(HttpMethod.Post, "/v1/campaigns", data, _client.GetComplianceBaseUrl(), cancellationToken); + } + + public CampaignResponse Create(CampaignRequest data) => CreateAsync(data).GetAwaiter().GetResult(); + + public async Task GetAsync(long id, CancellationToken cancellationToken = default) + { + return await _client.CustomRequestAsync(HttpMethod.Get, $"/v1/campaigns/{id}", null, _client.GetComplianceBaseUrl(), cancellationToken); + } + + public CampaignResponse Get(long id) => GetAsync(id).GetAwaiter().GetResult(); + + public async Task ListAsync(CancellationToken cancellationToken = default) + { + return await _client.CustomRequestAsync(HttpMethod.Get, "/v1/campaigns", null, _client.GetComplianceBaseUrl(), cancellationToken); + } + + public CampaignResponse[] List() => ListAsync().GetAwaiter().GetResult(); + + public async Task UpdateAsync(long id, CampaignRequest data, CancellationToken cancellationToken = default) + { + Validate(data, isCreate: false); + return await _client.CustomRequestAsync(HttpMethod.Patch, $"/v1/campaigns/{id}", data, _client.GetComplianceBaseUrl(), cancellationToken); + } + + public CampaignResponse Update(long id, CampaignRequest data) => UpdateAsync(id, data).GetAwaiter().GetResult(); + + public async Task DeleteAsync(long id, CancellationToken cancellationToken = default) + { + await _client.CustomRequestWithoutResponseAsync(HttpMethod.Delete, $"/v1/campaigns/{id}", null, _client.GetComplianceBaseUrl(), cancellationToken); + } + + public void Delete(long id) => DeleteAsync(id).GetAwaiter().GetResult(); + + private static void Validate(CampaignRequest data, bool isCreate) + { + var errors = new List(); + + if (isCreate) + { + if (data.BrandId == null) errors.Add("brandId is required"); + if (string.IsNullOrWhiteSpace(data.UseCase)) errors.Add("useCase is required"); + if (string.IsNullOrWhiteSpace(data.Description)) errors.Add("description is required"); + if (string.IsNullOrWhiteSpace(data.MessageFlow)) errors.Add("messageFlow is required"); + if (data.HasEmbeddedLinks == null) errors.Add("hasEmbeddedLinks is required"); + if (data.HasEmbeddedPhone == null) errors.Add("hasEmbeddedPhone is required"); + if (data.IsAgeGated == null) errors.Add("isAgeGated is required"); + if (data.IsDirectLending == null) errors.Add("isDirectLending is required"); + if (data.OptInKeywords == null || data.OptInKeywords.Count == 0) errors.Add("optInKeywords is required"); + if (string.IsNullOrWhiteSpace(data.OptInMessage)) errors.Add("optInMessage is required"); + if (string.IsNullOrWhiteSpace(data.OptInProofUrl)) errors.Add("optInProofUrl is required"); + if (data.HelpKeywords == null || data.HelpKeywords.Count == 0) errors.Add("helpKeywords is required"); + if (string.IsNullOrWhiteSpace(data.HelpMessage)) errors.Add("helpMessage is required"); + if (data.OptOutKeywords == null || data.OptOutKeywords.Count == 0) errors.Add("optOutKeywords is required"); + if (string.IsNullOrWhiteSpace(data.OptOutMessage)) errors.Add("optOutMessage is required"); + if (data.SampleMessages == null || data.SampleMessages.Count == 0) errors.Add("sampleMessages is required"); + } + + if (data.UseCase != null && !UseCases.Contains(data.UseCase)) + errors.Add("Invalid use case"); + + // MIXED/LOW_VOLUME_MIXED sub-use case validation + if (data.UseCase != null && MixedUseCases.Contains(data.UseCase)) + { + if (data.SubUseCases == null || data.SubUseCases.Count < 2 || data.SubUseCases.Count > 3) + errors.Add("MIXED/LOW_VOLUME_MIXED requires 2-3 sub use cases"); + else + foreach (var suc in data.SubUseCases) + if (!SubUseCases.Contains(suc)) errors.Add($"Invalid sub use case: {suc}"); + } + else if (data.UseCase != null && data.SubUseCases is { Count: > 0 }) + { + errors.Add("subUseCases should be empty for non-MIXED use cases"); + } + + // sampleMessages count and content validation + if (data.SampleMessages != null) + { + if (data.SampleMessages.Count < 2 || data.SampleMessages.Count > 5) + { + errors.Add("sampleMessages must contain 2-5 items"); + } + else + { + var optOutKws = data.OptOutKeywords ?? new List(); + var helpKws = data.HelpKeywords ?? new List(); + + var hasOptOut = data.SampleMessages.Any(msg => + msg.Contains("Reply STOP") || optOutKws.Any(kw => msg.Contains($"Reply {kw}"))); + if (!hasOptOut) + errors.Add("At least one sample must contain 'Reply STOP' or 'Reply {optOutKeyword}'"); + + var hasHelp = data.SampleMessages.Any(msg => + msg.Contains("Reply HELP") || helpKws.Any(kw => msg.Contains($"Reply {kw}"))); + if (!hasHelp) + errors.Add("At least one sample must contain 'Reply HELP' or 'Reply {helpKeyword}'"); + } + } + + // optOutMessage must contain STOP or an opt-out keyword + if (data.OptOutMessage != null) + { + var optOutKws = data.OptOutKeywords ?? new List(); + if (!data.OptOutMessage.Contains("STOP") && !optOutKws.Any(kw => data.OptOutMessage.Contains(kw))) + errors.Add("optOutMessage must contain 'STOP' or at least one optOutKeyword"); + } + + // helpMessage must contain HELP or a help keyword + if (data.HelpMessage != null) + { + var helpKws = data.HelpKeywords ?? new List(); + if (!data.HelpMessage.Contains("HELP") && !helpKws.Any(kw => data.HelpMessage.Contains(kw))) + errors.Add("helpMessage must contain 'HELP' or at least one helpKeyword"); + } + + if (data.OptInProofUrl != null && !data.OptInProofUrl.StartsWith("http://") && !data.OptInProofUrl.StartsWith("https://")) + errors.Add("Opt-in proof URL must start with http:// or https://"); + + if (data.TermsLink != null && !data.TermsLink.StartsWith("http://") && !data.TermsLink.StartsWith("https://")) + errors.Add("Terms link must start with http:// or https://"); + + if (data.PrivacyLink != null && !data.PrivacyLink.StartsWith("http://") && !data.PrivacyLink.StartsWith("https://")) + errors.Add("Privacy link must start with http:// or https://"); + + if (errors.Count > 0) + throw new ArgumentException($"Validation failed: {string.Join(", ", errors)}"); + } +} diff --git a/src/CCAI.NET/ContactValidator/ContactValidatorModels.cs b/src/CCAI.NET/ContactValidator/ContactValidatorModels.cs new file mode 100644 index 0000000..6b0ac52 --- /dev/null +++ b/src/CCAI.NET/ContactValidator/ContactValidatorModels.cs @@ -0,0 +1,139 @@ +// Copyright (c) 2025 CloudContactAI LLC +// Licensed under the MIT License. See LICENSE in the project root for license information. + +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace CCAI.NET.ContactValidator; + +/// +/// Validation result for a single email address +/// +public class EmailValidationResult +{ + /// + /// The validated email address + /// + [JsonPropertyName("contact")] + public string Contact { get; set; } = string.Empty; + + /// + /// Contact type — always "email" + /// + [JsonPropertyName("type")] + public string Type { get; set; } = string.Empty; + + /// + /// Validation status: "valid", "invalid", or "risky" + /// + [JsonPropertyName("status")] + public string Status { get; set; } = string.Empty; + + /// + /// Additional metadata (e.g. safe_to_send, ai_verdict) + /// + [JsonPropertyName("metadata")] + public Dictionary? Metadata { get; set; } +} + +/// +/// Validation result for a single phone number +/// +public class PhoneValidationResult +{ + /// + /// The validated phone number + /// + [JsonPropertyName("contact")] + public string Contact { get; set; } = string.Empty; + + /// + /// Contact type — always "phone" + /// + [JsonPropertyName("type")] + public string Type { get; set; } = string.Empty; + + /// + /// Validation status: "valid", "invalid", or "landline" + /// + [JsonPropertyName("status")] + public string Status { get; set; } = string.Empty; + + /// + /// Additional metadata (e.g. country_code, national_number, carrier_type) + /// + [JsonPropertyName("metadata")] + public Dictionary? Metadata { get; set; } +} + +/// +/// Aggregate counts for a bulk validation response +/// +public class ValidationSummary +{ + /// Total contacts validated + [JsonPropertyName("total")] + public int Total { get; set; } + + /// Number of valid contacts + [JsonPropertyName("valid")] + public int Valid { get; set; } + + /// Number of invalid contacts + [JsonPropertyName("invalid")] + public int Invalid { get; set; } + + /// Number of risky contacts (emails only) + [JsonPropertyName("risky")] + public int Risky { get; set; } + + /// Number of landline numbers (phones only) + [JsonPropertyName("landline")] + public int Landline { get; set; } +} + +/// +/// Response for a bulk email validation request +/// +public class BulkEmailValidationResult +{ + /// Individual validation results + [JsonPropertyName("results")] + public List Results { get; set; } = new(); + + /// Aggregate summary + [JsonPropertyName("summary")] + public ValidationSummary Summary { get; set; } = new(); +} + +/// +/// Response for a bulk phone validation request +/// +public class BulkPhoneValidationResult +{ + /// Individual validation results + [JsonPropertyName("results")] + public List Results { get; set; } = new(); + + /// Aggregate summary + [JsonPropertyName("summary")] + public ValidationSummary Summary { get; set; } = new(); +} + +/// +/// Phone number input for bulk validation +/// +public class PhoneInput +{ + /// + /// Phone number in E.164 format (e.g. +15551234567) + /// + [JsonPropertyName("phone")] + public string Phone { get; set; } = string.Empty; + + /// + /// Optional ISO 3166-1 alpha-2 country code (e.g. "US") + /// + [JsonPropertyName("countryCode")] + public string? CountryCode { get; set; } +} diff --git a/src/CCAI.NET/ContactValidator/ContactValidatorService.cs b/src/CCAI.NET/ContactValidator/ContactValidatorService.cs new file mode 100644 index 0000000..793c016 --- /dev/null +++ b/src/CCAI.NET/ContactValidator/ContactValidatorService.cs @@ -0,0 +1,104 @@ +// Copyright (c) 2025 CloudContactAI LLC +// Licensed under the MIT License. See LICENSE in the project root for license information. + +namespace CCAI.NET.ContactValidator; + +/// +/// Interface for the contact validator service +/// +public interface IContactValidatorService +{ + /// Validate a single email address (async) + Task ValidateEmailAsync(string email, CancellationToken cancellationToken = default); + + /// Validate multiple email addresses up to the configured bulk limit (async) + Task ValidateEmailsAsync(IEnumerable emails, CancellationToken cancellationToken = default); + + /// Validate a single phone number in E.164 format (async) + Task ValidatePhoneAsync(string phone, string? countryCode = null, CancellationToken cancellationToken = default); + + /// Validate multiple phone numbers up to the configured bulk limit (async) + Task ValidatePhonesAsync(IEnumerable phones, CancellationToken cancellationToken = default); + + /// Validate a single email address (synchronous) + EmailValidationResult ValidateEmail(string email); + + /// Validate multiple email addresses up to the configured bulk limit (synchronous) + BulkEmailValidationResult ValidateEmails(IEnumerable emails); + + /// Validate a single phone number in E.164 format (synchronous) + PhoneValidationResult ValidatePhone(string phone, string? countryCode = null); + + /// Validate multiple phone numbers up to the configured bulk limit (synchronous) + BulkPhoneValidationResult ValidatePhones(IEnumerable phones); +} + +/// +/// Service for validating email addresses and phone numbers through the CCAI API +/// +public class ContactValidatorService : IContactValidatorService +{ + private readonly ICCAIClient _client; + + /// + /// Create a new ContactValidatorService instance + /// + /// The parent CCAI client + public ContactValidatorService(ICCAIClient client) + { + _client = client; + } + + /// + /// Validate a single email address + /// + /// Email address to validate + /// Cancellation token + /// Validation result with status and metadata + public Task ValidateEmailAsync(string email, CancellationToken cancellationToken = default) => + _client.RequestAsync(HttpMethod.Post, "/v1/contact-validator/email", new { email }, cancellationToken); + + /// + /// Validate multiple email addresses (up to the configured bulk limit) + /// + /// List of email addresses to validate + /// Cancellation token + /// Bulk validation results with summary + public Task ValidateEmailsAsync(IEnumerable emails, CancellationToken cancellationToken = default) => + _client.RequestAsync(HttpMethod.Post, "/v1/contact-validator/emails", new { emails }, cancellationToken); + + /// + /// Validate a single phone number + /// + /// Phone number in E.164 format (e.g. +15551234567) + /// Optional ISO 3166-1 alpha-2 country code (e.g. "US") + /// Cancellation token + /// Validation result with status and metadata + public Task ValidatePhoneAsync(string phone, string? countryCode = null, CancellationToken cancellationToken = default) => + _client.RequestAsync(HttpMethod.Post, "/v1/contact-validator/phone", new { phone, countryCode }, cancellationToken); + + /// + /// Validate multiple phone numbers (up to the configured bulk limit) + /// + /// List of phone inputs with optional country codes + /// Cancellation token + /// Bulk validation results with summary + public Task ValidatePhonesAsync(IEnumerable phones, CancellationToken cancellationToken = default) => + _client.RequestAsync(HttpMethod.Post, "/v1/contact-validator/phones", new { phones }, cancellationToken); + + /// + public EmailValidationResult ValidateEmail(string email) => + ValidateEmailAsync(email).GetAwaiter().GetResult(); + + /// + public BulkEmailValidationResult ValidateEmails(IEnumerable emails) => + ValidateEmailsAsync(emails).GetAwaiter().GetResult(); + + /// + public PhoneValidationResult ValidatePhone(string phone, string? countryCode = null) => + ValidatePhoneAsync(phone, countryCode).GetAwaiter().GetResult(); + + /// + public BulkPhoneValidationResult ValidatePhones(IEnumerable phones) => + ValidatePhonesAsync(phones).GetAwaiter().GetResult(); +}