Skip to content

Commit 8e9c700

Browse files
authored
Add product email API (#39)
1 parent af4f8bd commit 8e9c700

9 files changed

Lines changed: 180 additions & 2 deletions

File tree

README.md

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -273,6 +273,19 @@ var phonebook = await client.Campaigns.CreatePhonebookAsync(new CreatePhonebookR
273273
});
274274
```
275275

276+
## Product Emails
277+
278+
Send a product notification email:
279+
280+
```csharp
281+
var email = await client.Emails.SendProductEmailAsync(new SendProductEmailRequest
282+
{
283+
EmailAddress = "person@example.com",
284+
TemplateId = "template-123",
285+
Subject = "Order update"
286+
});
287+
```
288+
276289
## Error Handling
277290

278291
The SDK throws `TermiiApiException` for non-success HTTP responses from Termii:
@@ -308,11 +321,11 @@ Implemented in the current SDK:
308321
- Tokens: send, verify, generate, voice, email, and WhatsApp OTP flows.
309322
- Insights: balance, DND status, number intelligence, message history, and message analytics.
310323
- Campaigns: list, create, update, and delete phonebooks.
324+
- Product emails: send template-based notification emails.
311325

312326
Deferred or not yet implemented:
313327

314328
- WhatsApp template/device message APIs.
315-
- Product notification email APIs.
316329

317330
See [docs/API_COVERAGE.md](docs/API_COVERAGE.md) for the detailed coverage matrix.
318331

docs/API_COVERAGE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ The Termii docs describe a REST/JSON API and state that each account has its own
6262
| Campaigns | Create phonebook | POST | `/api/phonebooks` | JSON body | `TermiiClient.Campaigns` | In progress | #33 | Unit tests added |
6363
| Campaigns | Update phonebook | PATCH | `/api/phonebooks/{phonebook_id}` | JSON body | `TermiiClient.Campaigns` | In progress | #33 | Unit tests added |
6464
| Campaigns | Delete phonebook | DELETE | `/api/phonebooks/{phonebook_id}` | Query | `TermiiClient.Campaigns` | In progress | #33 | Unit tests added |
65+
| Email | Send product notification email | POST | `/api/templates/send-email` | JSON body | `TermiiClient.Emails` | In progress | #31 | Unit tests added |
6566

6667
## Deferred Coverage
6768

@@ -71,7 +72,6 @@ The following documented APIs are useful but should come after the first SDK mil
7172
| --- | --- | --- | --- | --- | --- |
7273
| Messaging | Send WhatsApp template without media | POST | `/api/send/template` | Deferred | Requires WhatsApp template/device setup. |
7374
| Messaging | Send WhatsApp template with media | POST | `/api/send/template/media` | Deferred | Requires WhatsApp template/device setup and media payload modeling. |
74-
| Email | Send product notification email | POST | `/api/templates/send-email` | Deferred | Email notifications should be a separate milestone after SMS/token/insights. |
7575
| Insights | Webhook events and reports | N/A | Consumer webhook endpoint | Implemented | Receiver-side model support and README example covered by #32. |
7676

7777
## Postman Collection Reconciliation
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
namespace Termii;
2+
3+
public interface ITermiiEmailClient
4+
{
5+
Task<SendProductEmailResponse> SendProductEmailAsync(
6+
SendProductEmailRequest request,
7+
CancellationToken cancellationToken = default);
8+
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
using System.Text.Json;
2+
using System.Text.Json.Serialization;
3+
4+
namespace Termii;
5+
6+
public sealed class SendProductEmailRequest
7+
{
8+
[JsonPropertyName("email_address")]
9+
public string EmailAddress { get; set; } = string.Empty;
10+
11+
[JsonPropertyName("template_id")]
12+
public string TemplateId { get; set; } = string.Empty;
13+
14+
[JsonPropertyName("subject")]
15+
public string? Subject { get; set; }
16+
17+
[JsonPropertyName("from")]
18+
public string? From { get; set; }
19+
20+
[JsonPropertyName("sender_name")]
21+
public string? SenderName { get; set; }
22+
23+
[JsonPropertyName("data")]
24+
public Dictionary<string, JsonElement>? Data { get; set; }
25+
26+
internal void Validate()
27+
{
28+
TermiiRequestValidation.Required(EmailAddress, nameof(EmailAddress));
29+
TermiiRequestValidation.Required(TemplateId, nameof(TemplateId));
30+
}
31+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
using System.Text.Json;
2+
using System.Text.Json.Serialization;
3+
4+
namespace Termii;
5+
6+
public sealed class SendProductEmailResponse
7+
{
8+
[JsonPropertyName("code")]
9+
public string? Code { get; set; }
10+
11+
[JsonPropertyName("message")]
12+
public string? Message { get; set; }
13+
14+
[JsonPropertyName("message_id")]
15+
public string? MessageId { get; set; }
16+
17+
[JsonPropertyName("status")]
18+
public string? Status { get; set; }
19+
20+
[JsonExtensionData]
21+
public Dictionary<string, JsonElement>? AdditionalData { get; set; }
22+
}
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
namespace Termii;
2+
3+
internal sealed class TermiiEmailClient : ITermiiEmailClient
4+
{
5+
private readonly TermiiJsonHttpPipeline _pipeline;
6+
7+
public TermiiEmailClient(TermiiJsonHttpPipeline pipeline)
8+
{
9+
_pipeline = pipeline ?? throw new ArgumentNullException(nameof(pipeline));
10+
}
11+
12+
public Task<SendProductEmailResponse> SendProductEmailAsync(
13+
SendProductEmailRequest request,
14+
CancellationToken cancellationToken = default)
15+
{
16+
if (request is null)
17+
{
18+
throw new ArgumentNullException(nameof(request));
19+
}
20+
21+
request.Validate();
22+
23+
return _pipeline.SendJsonAsync<SendProductEmailResponse>(
24+
HttpMethod.Post,
25+
"/api/templates/send-email",
26+
request,
27+
TermiiAuthenticationLocation.Body,
28+
cancellationToken);
29+
}
30+
}

src/Termii/TermiiClient.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ private TermiiClient(HttpClient httpClient, TermiiOptions options, bool ownsHttp
4040
Tokens = new TermiiTokenClient(_pipeline);
4141
Insights = new TermiiInsightsClient(_pipeline);
4242
Campaigns = new TermiiCampaignClient(_pipeline);
43+
Emails = new TermiiEmailClient(_pipeline);
4344
}
4445

4546
public TermiiOptions Options { get; }
@@ -56,6 +57,8 @@ private TermiiClient(HttpClient httpClient, TermiiOptions options, bool ownsHttp
5657

5758
public ITermiiCampaignClient Campaigns { get; }
5859

60+
public ITermiiEmailClient Emails { get; }
61+
5962
public void Dispose()
6063
{
6164
if (_ownsHttpClient)

tests/Termii.IntegrationTests/TermiiClientIntegrationTests.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,5 +23,6 @@ public void CanCreateClientFromIntegrationEnvironment()
2323
Assert.NotNull(client.Tokens);
2424
Assert.NotNull(client.Insights);
2525
Assert.NotNull(client.Campaigns);
26+
Assert.NotNull(client.Emails);
2627
}
2728
}
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
using System.Net;
2+
using System.Text.Json;
3+
using Termii;
4+
using Termii.Tests.Infrastructure;
5+
using Xunit;
6+
7+
namespace Termii.Tests;
8+
9+
public sealed class TermiiEmailClientTests
10+
{
11+
[Fact]
12+
public async Task SendProductEmailAsyncPostsTemplateEmailBody()
13+
{
14+
using var handler = new TestHttpMessageHandler(
15+
HttpStatusCode.OK,
16+
"""{"code":"ok","message":"Email accepted","message_id":"email-123","status":"queued"}""");
17+
var client = TestTermiiClientFactory.Create(handler);
18+
19+
var response = await client.Emails.SendProductEmailAsync(
20+
new SendProductEmailRequest
21+
{
22+
EmailAddress = "person@example.com",
23+
TemplateId = "template-123",
24+
Subject = "Order update",
25+
From = "noreply@example.com",
26+
SenderName = "Example Store",
27+
Data = new Dictionary<string, JsonElement>
28+
{
29+
["first_name"] = JsonDocument.Parse("\"Ada\"").RootElement.Clone(),
30+
["order_id"] = JsonDocument.Parse("\"ORD-123\"").RootElement.Clone(),
31+
},
32+
},
33+
CancellationToken.None);
34+
35+
var request = handler.LastRequest;
36+
Assert.NotNull(request);
37+
using var body = await request.ReadJsonBodyAsync(CancellationToken.None);
38+
39+
Assert.Equal(HttpMethod.Post, request.Method);
40+
Assert.Equal("https://example.test/api/templates/send-email", request.RequestUri!.ToString());
41+
Assert.Equal("test-api-key", body.RootElement.GetProperty("api_key").GetString());
42+
Assert.Equal("person@example.com", body.RootElement.GetProperty("email_address").GetString());
43+
Assert.Equal("template-123", body.RootElement.GetProperty("template_id").GetString());
44+
Assert.Equal("Order update", body.RootElement.GetProperty("subject").GetString());
45+
Assert.Equal("noreply@example.com", body.RootElement.GetProperty("from").GetString());
46+
Assert.Equal("Example Store", body.RootElement.GetProperty("sender_name").GetString());
47+
Assert.Equal("Ada", body.RootElement.GetProperty("data").GetProperty("first_name").GetString());
48+
Assert.Equal("ORD-123", body.RootElement.GetProperty("data").GetProperty("order_id").GetString());
49+
50+
Assert.Equal("ok", response.Code);
51+
Assert.Equal("Email accepted", response.Message);
52+
Assert.Equal("email-123", response.MessageId);
53+
Assert.Equal("queued", response.Status);
54+
}
55+
56+
[Fact]
57+
public async Task SendProductEmailAsyncRejectsMissingRequiredFields()
58+
{
59+
using var handler = new TestHttpMessageHandler();
60+
var client = TestTermiiClientFactory.Create(handler);
61+
62+
await Assert.ThrowsAsync<ArgumentException>(() => client.Emails.SendProductEmailAsync(
63+
new SendProductEmailRequest
64+
{
65+
EmailAddress = "",
66+
TemplateId = "template-123",
67+
},
68+
CancellationToken.None));
69+
}
70+
}

0 commit comments

Comments
 (0)