From ffa5e57345cf1f18411798a2eb13bdfe4160ca4d Mon Sep 17 00:00:00 2001 From: Teesofttech Date: Sun, 14 Jun 2026 04:16:27 +0100 Subject: [PATCH] Add product email API --- README.md | 15 +++- docs/API_COVERAGE.md | 2 +- src/Termii/Emails/ITermiiEmailClient.cs | 8 +++ src/Termii/Emails/SendProductEmailRequest.cs | 31 ++++++++ src/Termii/Emails/SendProductEmailResponse.cs | 22 ++++++ src/Termii/Emails/TermiiEmailClient.cs | 30 ++++++++ src/Termii/TermiiClient.cs | 3 + .../TermiiClientIntegrationTests.cs | 1 + tests/Termii.Tests/TermiiEmailClientTests.cs | 70 +++++++++++++++++++ 9 files changed, 180 insertions(+), 2 deletions(-) create mode 100644 src/Termii/Emails/ITermiiEmailClient.cs create mode 100644 src/Termii/Emails/SendProductEmailRequest.cs create mode 100644 src/Termii/Emails/SendProductEmailResponse.cs create mode 100644 src/Termii/Emails/TermiiEmailClient.cs create mode 100644 tests/Termii.Tests/TermiiEmailClientTests.cs diff --git a/README.md b/README.md index 4ca9239..bb50e79 100644 --- a/README.md +++ b/README.md @@ -273,6 +273,19 @@ var phonebook = await client.Campaigns.CreatePhonebookAsync(new CreatePhonebookR }); ``` +## Product Emails + +Send a product notification email: + +```csharp +var email = await client.Emails.SendProductEmailAsync(new SendProductEmailRequest +{ + EmailAddress = "person@example.com", + TemplateId = "template-123", + Subject = "Order update" +}); +``` + ## Error Handling The SDK throws `TermiiApiException` for non-success HTTP responses from Termii: @@ -308,11 +321,11 @@ Implemented in the current SDK: - Tokens: send, verify, generate, voice, email, and WhatsApp OTP flows. - Insights: balance, DND status, number intelligence, message history, and message analytics. - Campaigns: list, create, update, and delete phonebooks. +- Product emails: send template-based notification emails. Deferred or not yet implemented: - WhatsApp template/device message APIs. -- Product notification email APIs. See [docs/API_COVERAGE.md](docs/API_COVERAGE.md) for the detailed coverage matrix. diff --git a/docs/API_COVERAGE.md b/docs/API_COVERAGE.md index 8792d26..3824162 100644 --- a/docs/API_COVERAGE.md +++ b/docs/API_COVERAGE.md @@ -62,6 +62,7 @@ The Termii docs describe a REST/JSON API and state that each account has its own | Campaigns | Create phonebook | POST | `/api/phonebooks` | JSON body | `TermiiClient.Campaigns` | In progress | #33 | Unit tests added | | Campaigns | Update phonebook | PATCH | `/api/phonebooks/{phonebook_id}` | JSON body | `TermiiClient.Campaigns` | In progress | #33 | Unit tests added | | Campaigns | Delete phonebook | DELETE | `/api/phonebooks/{phonebook_id}` | Query | `TermiiClient.Campaigns` | In progress | #33 | Unit tests added | +| Email | Send product notification email | POST | `/api/templates/send-email` | JSON body | `TermiiClient.Emails` | In progress | #31 | Unit tests added | ## Deferred Coverage @@ -71,7 +72,6 @@ The following documented APIs are useful but should come after the first SDK mil | --- | --- | --- | --- | --- | --- | | Messaging | Send WhatsApp template without media | POST | `/api/send/template` | Deferred | Requires WhatsApp template/device setup. | | Messaging | Send WhatsApp template with media | POST | `/api/send/template/media` | Deferred | Requires WhatsApp template/device setup and media payload modeling. | -| Email | Send product notification email | POST | `/api/templates/send-email` | Deferred | Email notifications should be a separate milestone after SMS/token/insights. | | Insights | Webhook events and reports | N/A | Consumer webhook endpoint | Implemented | Receiver-side model support and README example covered by #32. | ## Postman Collection Reconciliation diff --git a/src/Termii/Emails/ITermiiEmailClient.cs b/src/Termii/Emails/ITermiiEmailClient.cs new file mode 100644 index 0000000..a8ff5bb --- /dev/null +++ b/src/Termii/Emails/ITermiiEmailClient.cs @@ -0,0 +1,8 @@ +namespace Termii; + +public interface ITermiiEmailClient +{ + Task SendProductEmailAsync( + SendProductEmailRequest request, + CancellationToken cancellationToken = default); +} diff --git a/src/Termii/Emails/SendProductEmailRequest.cs b/src/Termii/Emails/SendProductEmailRequest.cs new file mode 100644 index 0000000..842dc94 --- /dev/null +++ b/src/Termii/Emails/SendProductEmailRequest.cs @@ -0,0 +1,31 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Termii; + +public sealed class SendProductEmailRequest +{ + [JsonPropertyName("email_address")] + public string EmailAddress { get; set; } = string.Empty; + + [JsonPropertyName("template_id")] + public string TemplateId { get; set; } = string.Empty; + + [JsonPropertyName("subject")] + public string? Subject { get; set; } + + [JsonPropertyName("from")] + public string? From { get; set; } + + [JsonPropertyName("sender_name")] + public string? SenderName { get; set; } + + [JsonPropertyName("data")] + public Dictionary? Data { get; set; } + + internal void Validate() + { + TermiiRequestValidation.Required(EmailAddress, nameof(EmailAddress)); + TermiiRequestValidation.Required(TemplateId, nameof(TemplateId)); + } +} diff --git a/src/Termii/Emails/SendProductEmailResponse.cs b/src/Termii/Emails/SendProductEmailResponse.cs new file mode 100644 index 0000000..5e72a7d --- /dev/null +++ b/src/Termii/Emails/SendProductEmailResponse.cs @@ -0,0 +1,22 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Termii; + +public sealed class SendProductEmailResponse +{ + [JsonPropertyName("code")] + public string? Code { get; set; } + + [JsonPropertyName("message")] + public string? Message { get; set; } + + [JsonPropertyName("message_id")] + public string? MessageId { get; set; } + + [JsonPropertyName("status")] + public string? Status { get; set; } + + [JsonExtensionData] + public Dictionary? AdditionalData { get; set; } +} diff --git a/src/Termii/Emails/TermiiEmailClient.cs b/src/Termii/Emails/TermiiEmailClient.cs new file mode 100644 index 0000000..b0867c2 --- /dev/null +++ b/src/Termii/Emails/TermiiEmailClient.cs @@ -0,0 +1,30 @@ +namespace Termii; + +internal sealed class TermiiEmailClient : ITermiiEmailClient +{ + private readonly TermiiJsonHttpPipeline _pipeline; + + public TermiiEmailClient(TermiiJsonHttpPipeline pipeline) + { + _pipeline = pipeline ?? throw new ArgumentNullException(nameof(pipeline)); + } + + public Task SendProductEmailAsync( + SendProductEmailRequest request, + CancellationToken cancellationToken = default) + { + if (request is null) + { + throw new ArgumentNullException(nameof(request)); + } + + request.Validate(); + + return _pipeline.SendJsonAsync( + HttpMethod.Post, + "/api/templates/send-email", + request, + TermiiAuthenticationLocation.Body, + cancellationToken); + } +} diff --git a/src/Termii/TermiiClient.cs b/src/Termii/TermiiClient.cs index 65d346b..68f9572 100644 --- a/src/Termii/TermiiClient.cs +++ b/src/Termii/TermiiClient.cs @@ -40,6 +40,7 @@ private TermiiClient(HttpClient httpClient, TermiiOptions options, bool ownsHttp Tokens = new TermiiTokenClient(_pipeline); Insights = new TermiiInsightsClient(_pipeline); Campaigns = new TermiiCampaignClient(_pipeline); + Emails = new TermiiEmailClient(_pipeline); } public TermiiOptions Options { get; } @@ -56,6 +57,8 @@ private TermiiClient(HttpClient httpClient, TermiiOptions options, bool ownsHttp public ITermiiCampaignClient Campaigns { get; } + public ITermiiEmailClient Emails { get; } + public void Dispose() { if (_ownsHttpClient) diff --git a/tests/Termii.IntegrationTests/TermiiClientIntegrationTests.cs b/tests/Termii.IntegrationTests/TermiiClientIntegrationTests.cs index b8d6f83..820f433 100644 --- a/tests/Termii.IntegrationTests/TermiiClientIntegrationTests.cs +++ b/tests/Termii.IntegrationTests/TermiiClientIntegrationTests.cs @@ -23,5 +23,6 @@ public void CanCreateClientFromIntegrationEnvironment() Assert.NotNull(client.Tokens); Assert.NotNull(client.Insights); Assert.NotNull(client.Campaigns); + Assert.NotNull(client.Emails); } } diff --git a/tests/Termii.Tests/TermiiEmailClientTests.cs b/tests/Termii.Tests/TermiiEmailClientTests.cs new file mode 100644 index 0000000..8194c0e --- /dev/null +++ b/tests/Termii.Tests/TermiiEmailClientTests.cs @@ -0,0 +1,70 @@ +using System.Net; +using System.Text.Json; +using Termii; +using Termii.Tests.Infrastructure; +using Xunit; + +namespace Termii.Tests; + +public sealed class TermiiEmailClientTests +{ + [Fact] + public async Task SendProductEmailAsyncPostsTemplateEmailBody() + { + using var handler = new TestHttpMessageHandler( + HttpStatusCode.OK, + """{"code":"ok","message":"Email accepted","message_id":"email-123","status":"queued"}"""); + var client = TestTermiiClientFactory.Create(handler); + + var response = await client.Emails.SendProductEmailAsync( + new SendProductEmailRequest + { + EmailAddress = "person@example.com", + TemplateId = "template-123", + Subject = "Order update", + From = "noreply@example.com", + SenderName = "Example Store", + Data = new Dictionary + { + ["first_name"] = JsonDocument.Parse("\"Ada\"").RootElement.Clone(), + ["order_id"] = JsonDocument.Parse("\"ORD-123\"").RootElement.Clone(), + }, + }, + CancellationToken.None); + + var request = handler.LastRequest; + Assert.NotNull(request); + using var body = await request.ReadJsonBodyAsync(CancellationToken.None); + + Assert.Equal(HttpMethod.Post, request.Method); + Assert.Equal("https://example.test/api/templates/send-email", request.RequestUri!.ToString()); + Assert.Equal("test-api-key", body.RootElement.GetProperty("api_key").GetString()); + Assert.Equal("person@example.com", body.RootElement.GetProperty("email_address").GetString()); + Assert.Equal("template-123", body.RootElement.GetProperty("template_id").GetString()); + Assert.Equal("Order update", body.RootElement.GetProperty("subject").GetString()); + Assert.Equal("noreply@example.com", body.RootElement.GetProperty("from").GetString()); + Assert.Equal("Example Store", body.RootElement.GetProperty("sender_name").GetString()); + Assert.Equal("Ada", body.RootElement.GetProperty("data").GetProperty("first_name").GetString()); + Assert.Equal("ORD-123", body.RootElement.GetProperty("data").GetProperty("order_id").GetString()); + + Assert.Equal("ok", response.Code); + Assert.Equal("Email accepted", response.Message); + Assert.Equal("email-123", response.MessageId); + Assert.Equal("queued", response.Status); + } + + [Fact] + public async Task SendProductEmailAsyncRejectsMissingRequiredFields() + { + using var handler = new TestHttpMessageHandler(); + var client = TestTermiiClientFactory.Create(handler); + + await Assert.ThrowsAsync(() => client.Emails.SendProductEmailAsync( + new SendProductEmailRequest + { + EmailAddress = "", + TemplateId = "template-123", + }, + CancellationToken.None)); + } +}