Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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.

Expand Down
2 changes: 1 addition & 1 deletion docs/API_COVERAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand Down
8 changes: 8 additions & 0 deletions src/Termii/Emails/ITermiiEmailClient.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
namespace Termii;

public interface ITermiiEmailClient
{
Task<SendProductEmailResponse> SendProductEmailAsync(
SendProductEmailRequest request,
CancellationToken cancellationToken = default);
}
31 changes: 31 additions & 0 deletions src/Termii/Emails/SendProductEmailRequest.cs
Original file line number Diff line number Diff line change
@@ -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<string, JsonElement>? Data { get; set; }

internal void Validate()
{
TermiiRequestValidation.Required(EmailAddress, nameof(EmailAddress));
TermiiRequestValidation.Required(TemplateId, nameof(TemplateId));
}
}
22 changes: 22 additions & 0 deletions src/Termii/Emails/SendProductEmailResponse.cs
Original file line number Diff line number Diff line change
@@ -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<string, JsonElement>? AdditionalData { get; set; }
}
30 changes: 30 additions & 0 deletions src/Termii/Emails/TermiiEmailClient.cs
Original file line number Diff line number Diff line change
@@ -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<SendProductEmailResponse> SendProductEmailAsync(
SendProductEmailRequest request,
CancellationToken cancellationToken = default)
{
if (request is null)
{
throw new ArgumentNullException(nameof(request));
}

request.Validate();

return _pipeline.SendJsonAsync<SendProductEmailResponse>(
HttpMethod.Post,
"/api/templates/send-email",
request,
TermiiAuthenticationLocation.Body,
cancellationToken);
}
}
3 changes: 3 additions & 0 deletions src/Termii/TermiiClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
Expand All @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,5 +23,6 @@ public void CanCreateClientFromIntegrationEnvironment()
Assert.NotNull(client.Tokens);
Assert.NotNull(client.Insights);
Assert.NotNull(client.Campaigns);
Assert.NotNull(client.Emails);
}
}
70 changes: 70 additions & 0 deletions tests/Termii.Tests/TermiiEmailClientTests.cs
Original file line number Diff line number Diff line change
@@ -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<string, JsonElement>
{
["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<ArgumentException>(() => client.Emails.SendProductEmailAsync(
new SendProductEmailRequest
{
EmailAddress = "",
TemplateId = "template-123",
},
CancellationToken.None));
}
}
Loading