diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..4e83db9 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,19 @@ +# Changelog + +## 2.0.0 + +Breaking rewrite for Node SDK parity (version aligned with Node/Python/Go/Java `2.0.0`): + +- Typed params/responses (`SendMailParams`, `ContactModels`, etc.) instead of untyped dictionaries for mail/contacts +- `ReloopValidationException` for invalid client input (no HTTP); `ReloopApiException` for HTTP/network; `WebhookSignatureException` for local HMAC verify +- Facade: `client.ApiKey` (was `ApiKeys`), `Mail`, `Domain`, `Contacts`, `Webhook`, `Inbox` +- Typed Contacts with nested `Properties`, `Groups`, `Channels` +- Full Webhook CRUD + `PauseAsync`/`EnableAsync`/`DisableAsync`/`TriggerAsync`/deliveries + local HMAC `WebhookVerify` +- Full Inbox: `Mailboxes`, `Messages`, `Threads` +- Removed: API key `PauseAsync`, domain `GetNameserversAsync` / `ForwardDnsAsync` +- Paths aligned with Node (`/api/mail/v1/send`, `/api/api-key/v1/`, `/api/domain/v1/`, …) +- xUnit tests with `MockHttpMessageHandler` and surface-lock checks + +## 0.1.0 + +Initial thin client for mail, domain, API keys, and dictionary-based contacts. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 42c0043..b00d808 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,12 +1,12 @@ # Contributing to the Reloop .NET SDK -NuGet package: **`Reloop`**. +NuGet package: **`Reloop`** (version aligned with Node/Python/Go/Java at **2.0.0**). **License:** [Apache License 2.0](./LICENSE) with additional use restrictions from Reloop Labs. **API reference:** [reloop.sh/docs](https://reloop.sh/docs) -Port new endpoints from the [Node.js SDK](https://github.com/reloop-labs/reloop-node) reference. +Port new endpoints from the [Node.js SDK](https://github.com/reloop-labs/reloop-node) — Node wins on paths, bodies, and validation messages. --- @@ -18,18 +18,21 @@ cd reloop-dotnet dotnet test tests/Reloop.Tests/Reloop.Tests.csproj ``` -Requires **.NET 8 SDK** (CI); targets **netstandard2.0**. +Requires **.NET 8 SDK** for tests; the library targets **netstandard2.0** (`System.Text.Json`). --- ## Project layout -``` +```text ReloopClient.cs -Services/ # MailService, DomainService, … -Models/Models.cs # Records with JsonPropertyName -tests/Reloop.Tests/ # Route tests + MockHttpMessageHandler -Reloop.csproj # Version, PackageVersion, PackageLicenseExpression +Version.cs +Exceptions/ # ReloopValidationException, ReloopApiException, ApiErrorBody, WebhookSignatureException +Validation/ # Validators +Models/ # MailModels, ApiKeyModels, DomainModels, ContactModels, WebhookModels, InboxModels +Services/ # Mail, ApiKey, Domain, Contacts*, Webhook*, Inbox* +tests/Reloop.Tests/ # Route + validation + surface-lock tests, MockHttpMessageHandler +Reloop.csproj # Version, PackageVersion, PackageLicenseExpression ``` --- @@ -38,29 +41,34 @@ Reloop.csproj # Version, PackageVersion, PackageLicenseExpression | Topic | Rule | |-------|------| -| Domain | Typed records; snake_case JSON names | -| Mail send | `Dictionary` with snake_case keys | -| Contacts | camelCase via request helpers | -| Tests | Inject `HttpClient` with `MockHttpMessageHandler` | +| Errors | `ReloopValidationException` (no HTTP) / `ReloopApiException` (HTTP/network) / `WebhookSignatureException` (local HMAC) | +| Wire JSON | Match Node (`[JsonPropertyName]` for snake_case vs camelCase) | +| Validation | Fail before HTTP via `Validators`; tests assert **0** requests on validation failures | +| Async API | Public methods end with `Async`; inject `HttpClient` in ctor for tests | +| Tests | `MockHttpMessageHandler`: method, path, `x-api-key`, body; surface-lock method sets | +| Endpoints | Do not invent — copy from Node `paths.ts` / Java services | +| Version | Same semver as Node/Python/Go/Java (`Version.VERSION` + `Reloop.csproj`) | --- ## Pull request checklist - [ ] `dotnet test tests/Reloop.Tests/Reloop.Tests.csproj` passes -- [ ] `` in `Reloop.csproj` bumped only for releases +- [ ] New ops have happy path + API error + validation (0 HTTP) + surface test where applicable +- [ ] `Reloop.csproj` / `Version.cs` updated only when releasing --- ## Releasing -Version: **`Reloop.csproj`** → `` and ``. +1. Set `` and `` in `Reloop.csproj` and `Version.VERSION` to the same value (match other SDKs) +2. Tag and publish via existing GitHub Actions workflows ```bash -git commit -am "chore: release v1.9.0" +git commit -am "chore: release v2.0.0" git push origin main -git tag v1.9.0 -git push origin v1.9.0 +git tag v2.0.0 +git push origin v2.0.0 ``` [`.github/workflows/release.yml`](./.github/workflows/release.yml) uploads source zip + `.nupkg` files. diff --git a/Exceptions/ApiErrorBody.cs b/Exceptions/ApiErrorBody.cs new file mode 100644 index 0000000..5940a9f --- /dev/null +++ b/Exceptions/ApiErrorBody.cs @@ -0,0 +1,19 @@ +using System.Text.Json.Serialization; + +namespace Reloop.Exceptions; + +/** Decoded Reloop API error payload. */ +public class ApiErrorBody +{ + [JsonPropertyName("message")] + public string? Message { get; set; } + + [JsonPropertyName("why")] + public string? Why { get; set; } + + [JsonPropertyName("tip")] + public string? Tip { get; set; } + + [JsonPropertyName("link")] + public string? Link { get; set; } +} diff --git a/Exceptions/ReloopApiException.cs b/Exceptions/ReloopApiException.cs new file mode 100644 index 0000000..f114022 --- /dev/null +++ b/Exceptions/ReloopApiException.cs @@ -0,0 +1,40 @@ +namespace Reloop.Exceptions; + +/** Thrown for non-2xx HTTP responses and network failures. */ +public class ReloopApiException : Exception +{ + public int Status { get; } + public string StatusText { get; } + public ApiErrorBody Body { get; } + + public ReloopApiException(int status, string statusText, ApiErrorBody? body) + : base(BuildMessage(status, statusText, body)) + { + Status = status; + StatusText = statusText; + Body = body ?? new ApiErrorBody(); + } + + public ReloopApiException(string message, Exception innerException) + : base(message, innerException) + { + Status = 0; + StatusText = "Network Error"; + Body = new ApiErrorBody { Message = message }; + } + + private static string BuildMessage(int status, string statusText, ApiErrorBody? body) + { + if (body?.Message is { Length: > 0 } msg) + { + return msg; + } + + if (status == 0) + { + return "Reloop network error: " + statusText; + } + + return "Reloop API Error: " + status + " " + statusText; + } +} diff --git a/Exceptions/ReloopValidationException.cs b/Exceptions/ReloopValidationException.cs new file mode 100644 index 0000000..fac1a46 --- /dev/null +++ b/Exceptions/ReloopValidationException.cs @@ -0,0 +1,18 @@ +namespace Reloop.Exceptions; + +/** Thrown when client arguments are invalid (no HTTP call is made). */ +public class ReloopValidationException : Exception +{ + public string? Field { get; } + + public ReloopValidationException(string message) + : this(message, null) + { + } + + public ReloopValidationException(string message, string? field) + : base(message) + { + Field = field; + } +} diff --git a/Exceptions/WebhookSignatureException.cs b/Exceptions/WebhookSignatureException.cs new file mode 100644 index 0000000..c507286 --- /dev/null +++ b/Exceptions/WebhookSignatureException.cs @@ -0,0 +1,10 @@ +namespace Reloop.Exceptions; + +/** Thrown when webhook signature verification fails. */ +public class WebhookSignatureException : Exception +{ + public WebhookSignatureException(string message) + : base(message) + { + } +} diff --git a/Models/ApiKeyModels.cs b/Models/ApiKeyModels.cs new file mode 100644 index 0000000..e3c713b --- /dev/null +++ b/Models/ApiKeyModels.cs @@ -0,0 +1,201 @@ +using System.Text.Json.Serialization; + +namespace Reloop.Models; + +public static class ApiKeyModels +{ + public class User + { + [JsonPropertyName("id")] + public string? Id { get; set; } + + [JsonPropertyName("name")] + public string? Name { get; set; } + + [JsonPropertyName("image")] + public string? Image { get; set; } + + [JsonPropertyName("email")] + public string? Email { get; set; } + } + + public class ApiKey + { + [JsonPropertyName("id")] + public string? Id { get; set; } + + [JsonPropertyName("name")] + public string? Name { get; set; } + + [JsonPropertyName("start")] + public string? Start { get; set; } + + [JsonPropertyName("prefix")] + public string? Prefix { get; set; } + + [JsonPropertyName("organizationId")] + public string? OrganizationId { get; set; } + + [JsonPropertyName("userId")] + public string? UserId { get; set; } + + [JsonPropertyName("refillInterval")] + public int? RefillInterval { get; set; } + + [JsonPropertyName("refillAmount")] + public int? RefillAmount { get; set; } + + [JsonPropertyName("lastRefillAt")] + public string? LastRefillAt { get; set; } + + [JsonPropertyName("enabled")] + public bool Enabled { get; set; } + + [JsonPropertyName("rateLimitEnabled")] + public bool RateLimitEnabled { get; set; } + + [JsonPropertyName("rateLimitTimeWindow")] + public int RateLimitTimeWindow { get; set; } + + [JsonPropertyName("rateLimitMax")] + public int RateLimitMax { get; set; } + + [JsonPropertyName("requestCount")] + public int RequestCount { get; set; } + + [JsonPropertyName("remaining")] + public int? Remaining { get; set; } + + [JsonPropertyName("lastRequest")] + public string? LastRequest { get; set; } + + [JsonPropertyName("expiresAt")] + public string? ExpiresAt { get; set; } + + [JsonPropertyName("createdAt")] + public string? CreatedAt { get; set; } + + [JsonPropertyName("updatedAt")] + public string? UpdatedAt { get; set; } + + [JsonPropertyName("permissions")] + public string? Permissions { get; set; } + + [JsonPropertyName("metadata")] + public string? Metadata { get; set; } + + [JsonPropertyName("createdBy")] + public User? CreatedBy { get; set; } + + [JsonPropertyName("object")] + public string? Object { get; set; } + + [JsonPropertyName("event")] + public string? Event { get; set; } + } + + public class ApiKeyWithKey + { + [JsonPropertyName("id")] + public string? Id { get; set; } + + [JsonPropertyName("name")] + public string? Name { get; set; } + + [JsonPropertyName("key")] + public string? Key { get; set; } + + [JsonPropertyName("enabled")] + public bool Enabled { get; set; } + + [JsonPropertyName("createdAt")] + public string? CreatedAt { get; set; } + + [JsonPropertyName("updatedAt")] + public string? UpdatedAt { get; set; } + + [JsonPropertyName("permissions")] + public string? Permissions { get; set; } + + [JsonPropertyName("object")] + public string? Object { get; set; } + + [JsonPropertyName("event")] + public string? Event { get; set; } + } + + public class ApiKeyListResponse + { + [JsonPropertyName("object")] + public string? Object { get; set; } + + [JsonPropertyName("apiKeys")] + public List? ApiKeys { get; set; } + + [JsonPropertyName("total")] + public int Total { get; set; } + + [JsonPropertyName("page")] + public int Page { get; set; } + + [JsonPropertyName("limit")] + public int Limit { get; set; } + + [JsonPropertyName("event")] + public string? Event { get; set; } + } + + public class ApiKeyListParams + { + public int? Page { get; set; } + public int? Limit { get; set; } + public bool? Enabled { get; set; } + public string? UserId { get; set; } + public string? Q { get; set; } + } + + public class DeleteApiKeyResponse + { + [JsonPropertyName("id")] + public string? Id { get; set; } + + [JsonPropertyName("message")] + public string? Message { get; set; } + + [JsonPropertyName("object")] + public string? Object { get; set; } + + [JsonPropertyName("event")] + public string? Event { get; set; } + } + + public class CreateApiKeyParams + { + [JsonPropertyName("name")] + public string? Name { get; set; } + + public CreateApiKeyParams() + { + } + + public CreateApiKeyParams(string name) + { + Name = name; + } + } + + public class UpdateApiKeyParams + { + [JsonPropertyName("name")] + public string? Name { get; set; } + + public UpdateApiKeyParams() + { + } + + public UpdateApiKeyParams(string name) + { + Name = name; + } + } +} diff --git a/Models/ContactModels.cs b/Models/ContactModels.cs new file mode 100644 index 0000000..a0a62b1 --- /dev/null +++ b/Models/ContactModels.cs @@ -0,0 +1,729 @@ +using System.Text.Json.Serialization; + +namespace Reloop.Models; + +public static class ContactModels +{ + public static class ContactStatus + { + public const string Subscribed = "subscribed"; + public const string Unsubscribed = "unsubscribed"; + public const string Blocked = "blocked"; + } + + public static class ChannelSubscription + { + public const string OptIn = "opt_in"; + public const string OptOut = "opt_out"; + } + + public static class ChannelVisibility + { + public const string Private = "private"; + public const string Public = "public"; + } + + public static class PropertyType + { + public const string String = "string"; + public const string Number = "number"; + } + + public class ContactChannelInput + { + [JsonPropertyName("channelId")] + public string? ChannelId { get; set; } + + [JsonPropertyName("subscription")] + public string? Subscription { get; set; } + + public ContactChannelInput() + { + } + + public ContactChannelInput(string channelId, string subscription) + { + ChannelId = channelId; + Subscription = subscription; + } + } + + public class ContactGroupRef + { + [JsonPropertyName("id")] + public string? Id { get; set; } + + [JsonPropertyName("name")] + public string? Name { get; set; } + } + + public class ContactChannelRef + { + [JsonPropertyName("id")] + public string? Id { get; set; } + + [JsonPropertyName("name")] + public string? Name { get; set; } + + [JsonPropertyName("subscription")] + public string? Subscription { get; set; } + } + + public class Contact + { + [JsonPropertyName("object")] + public string? Object { get; set; } + + [JsonPropertyName("id")] + public string? Id { get; set; } + + [JsonPropertyName("email")] + public string? Email { get; set; } + + [JsonPropertyName("firstName")] + public string? FirstName { get; set; } + + [JsonPropertyName("lastName")] + public string? LastName { get; set; } + + [JsonPropertyName("status")] + public string? Status { get; set; } + + [JsonPropertyName("properties")] + public Dictionary? Properties { get; set; } + + [JsonPropertyName("groups")] + public List? Groups { get; set; } + + [JsonPropertyName("channels")] + public List? Channels { get; set; } + + [JsonPropertyName("suppressionReason")] + public string? SuppressionReason { get; set; } + + [JsonPropertyName("suppressedAt")] + public string? SuppressedAt { get; set; } + + [JsonPropertyName("createdAt")] + public string? CreatedAt { get; set; } + + [JsonPropertyName("updatedAt")] + public string? UpdatedAt { get; set; } + } + + public class ContactResponse : Contact + { + [JsonPropertyName("event")] + public string? Event { get; set; } + } + + public class CreateContactParams + { + [JsonPropertyName("email")] + public string? Email { get; set; } + + [JsonPropertyName("firstName")] + public string? FirstName { get; set; } + + [JsonPropertyName("lastName")] + public string? LastName { get; set; } + + [JsonPropertyName("status")] + public string? Status { get; set; } + + [JsonPropertyName("properties")] + public Dictionary? Properties { get; set; } + + [JsonPropertyName("groupIds")] + public List? GroupIds { get; set; } + + [JsonPropertyName("channels")] + public List? Channels { get; set; } + } + + public class UpdateContactParams + { + [JsonPropertyName("email")] + public string? Email { get; set; } + + [JsonPropertyName("firstName")] + public string? FirstName { get; set; } + + [JsonPropertyName("lastName")] + public string? LastName { get; set; } + + [JsonPropertyName("status")] + public string? Status { get; set; } + + [JsonPropertyName("properties")] + public Dictionary? Properties { get; set; } + } + + public class ListContactsParams + { + public int? Page { get; set; } + public int? Limit { get; set; } + public string? Search { get; set; } + public string? Status { get; set; } + } + + public class ContactListResponse + { + [JsonPropertyName("object")] + public string? Object { get; set; } + + [JsonPropertyName("contacts")] + public List? Contacts { get; set; } + + [JsonPropertyName("total")] + public int Total { get; set; } + + [JsonPropertyName("page")] + public int Page { get; set; } + + [JsonPropertyName("limit")] + public int Limit { get; set; } + + [JsonPropertyName("totalContacts")] + public int TotalContacts { get; set; } + + [JsonPropertyName("subscribedContacts")] + public int SubscribedContacts { get; set; } + + [JsonPropertyName("unsubscribedContacts")] + public int UnsubscribedContacts { get; set; } + + [JsonPropertyName("event")] + public string? Event { get; set; } + } + + public class DeleteContactResponse + { + [JsonPropertyName("success")] + public bool Success { get; set; } + + [JsonPropertyName("object")] + public string? Object { get; set; } + + [JsonPropertyName("id")] + public string? Id { get; set; } + + [JsonPropertyName("event")] + public string? Event { get; set; } + } + + public class ContactProperty + { + [JsonPropertyName("object")] + public string? Object { get; set; } + + [JsonPropertyName("id")] + public string? Id { get; set; } + + [JsonPropertyName("propertyName")] + public string? PropertyName { get; set; } + + [JsonPropertyName("propertyType")] + public string? PropertyType { get; set; } + + [JsonPropertyName("defaultValue")] + public string? DefaultValue { get; set; } + + [JsonPropertyName("createdAt")] + public string? CreatedAt { get; set; } + + [JsonPropertyName("updatedAt")] + public string? UpdatedAt { get; set; } + } + + public class ContactPropertyResponse : ContactProperty + { + [JsonPropertyName("event")] + public string? Event { get; set; } + } + + public class ContactPropertyListItem + { + [JsonPropertyName("id")] + public string? Id { get; set; } + + [JsonPropertyName("propertyName")] + public string? PropertyName { get; set; } + + [JsonPropertyName("propertyType")] + public string? PropertyType { get; set; } + + [JsonPropertyName("defaultValue")] + public string? DefaultValue { get; set; } + + [JsonPropertyName("createdAt")] + public string? CreatedAt { get; set; } + + [JsonPropertyName("updatedAt")] + public string? UpdatedAt { get; set; } + } + + public class CreatePropertyParams + { + [JsonPropertyName("name")] + public string? Name { get; set; } + + [JsonPropertyName("type")] + public string? Type { get; set; } + + [JsonPropertyName("fallbackValue")] + public string? FallbackValue { get; set; } + } + + public class UpdatePropertyParams + { + [JsonPropertyName("fallbackValue")] + public string? FallbackValue { get; set; } + } + + public class ListPropertiesParams + { + public int? Page { get; set; } + public int? Limit { get; set; } + public string? Search { get; set; } + public string? Type { get; set; } + } + + public class PropertyListResponse + { + [JsonPropertyName("object")] + public string? Object { get; set; } + + [JsonPropertyName("properties")] + public List? Properties { get; set; } + + [JsonPropertyName("total")] + public int Total { get; set; } + + [JsonPropertyName("page")] + public int Page { get; set; } + + [JsonPropertyName("limit")] + public int Limit { get; set; } + + [JsonPropertyName("event")] + public string? Event { get; set; } + } + + public class DeletePropertyResponse + { + [JsonPropertyName("object")] + public string? Object { get; set; } + + [JsonPropertyName("success")] + public bool Success { get; set; } + + [JsonPropertyName("id")] + public string? Id { get; set; } + + [JsonPropertyName("name")] + public string? Name { get; set; } + + [JsonPropertyName("event")] + public string? Event { get; set; } + } + + public class ContactGroup + { + [JsonPropertyName("object")] + public string? Object { get; set; } + + [JsonPropertyName("id")] + public string? Id { get; set; } + + [JsonPropertyName("name")] + public string? Name { get; set; } + + [JsonPropertyName("createdAt")] + public string? CreatedAt { get; set; } + + [JsonPropertyName("updatedAt")] + public string? UpdatedAt { get; set; } + } + + public class ContactGroupResponse : ContactGroup + { + [JsonPropertyName("event")] + public string? Event { get; set; } + } + + public class ContactGroupListItem + { + [JsonPropertyName("id")] + public string? Id { get; set; } + + [JsonPropertyName("name")] + public string? Name { get; set; } + + [JsonPropertyName("createdAt")] + public string? CreatedAt { get; set; } + + [JsonPropertyName("updatedAt")] + public string? UpdatedAt { get; set; } + } + + public class CreateGroupParams + { + [JsonPropertyName("name")] + public string? Name { get; set; } + + public CreateGroupParams() + { + } + + public CreateGroupParams(string name) + { + Name = name; + } + } + + public class UpdateGroupParams + { + [JsonPropertyName("name")] + public string? Name { get; set; } + + public UpdateGroupParams() + { + } + + public UpdateGroupParams(string name) + { + Name = name; + } + } + + public class ListGroupsParams + { + public int? Page { get; set; } + public int? Limit { get; set; } + public string? Search { get; set; } + } + + public class GroupListResponse + { + [JsonPropertyName("object")] + public string? Object { get; set; } + + [JsonPropertyName("groups")] + public List? Groups { get; set; } + + [JsonPropertyName("total")] + public int Total { get; set; } + + [JsonPropertyName("page")] + public int Page { get; set; } + + [JsonPropertyName("limit")] + public int Limit { get; set; } + + [JsonPropertyName("event")] + public string? Event { get; set; } + } + + public class DeleteGroupResponse + { + [JsonPropertyName("object")] + public string? Object { get; set; } + + [JsonPropertyName("success")] + public bool Success { get; set; } + + [JsonPropertyName("id")] + public string? Id { get; set; } + + [JsonPropertyName("name")] + public string? Name { get; set; } + + [JsonPropertyName("event")] + public string? Event { get; set; } + } + + public class ListGroupContactsParams + { + public int? Page { get; set; } + public int? Limit { get; set; } + public string? Search { get; set; } + public string? Status { get; set; } + } + + public class GroupContactItem + { + [JsonPropertyName("id")] + public string? Id { get; set; } + + [JsonPropertyName("email")] + public string? Email { get; set; } + + [JsonPropertyName("firstName")] + public string? FirstName { get; set; } + + [JsonPropertyName("lastName")] + public string? LastName { get; set; } + + [JsonPropertyName("status")] + public string? Status { get; set; } + + [JsonPropertyName("properties")] + public Dictionary? Properties { get; set; } + + [JsonPropertyName("createdAt")] + public string? CreatedAt { get; set; } + + [JsonPropertyName("updatedAt")] + public string? UpdatedAt { get; set; } + } + + public class GroupContactListResponse + { + [JsonPropertyName("object")] + public string? Object { get; set; } + + [JsonPropertyName("group")] + public ContactGroupRef? Group { get; set; } + + [JsonPropertyName("contacts")] + public List? Contacts { get; set; } + + [JsonPropertyName("total")] + public int Total { get; set; } + + [JsonPropertyName("page")] + public int Page { get; set; } + + [JsonPropertyName("limit")] + public int Limit { get; set; } + + [JsonPropertyName("event")] + public string? Event { get; set; } + } + + public class GroupMembershipParams + { + [JsonPropertyName("contact_id")] + public string? ContactId { get; set; } + + [JsonPropertyName("email")] + public string? Email { get; set; } + } + + public class AddContactToGroupResponse + { + [JsonPropertyName("success")] + public bool Success { get; set; } + + [JsonPropertyName("object")] + public string? Object { get; set; } + + [JsonPropertyName("id")] + public string? Id { get; set; } + + [JsonPropertyName("event")] + public string? Event { get; set; } + } + + public class RemoveContactFromGroupResponse + { + [JsonPropertyName("success")] + public bool Success { get; set; } + + [JsonPropertyName("object")] + public string? Object { get; set; } + + [JsonPropertyName("id")] + public string? Id { get; set; } + + [JsonPropertyName("event")] + public string? Event { get; set; } + } + + public class ContactChannel + { + [JsonPropertyName("object")] + public string? Object { get; set; } + + [JsonPropertyName("id")] + public string? Id { get; set; } + + [JsonPropertyName("name")] + public string? Name { get; set; } + + [JsonPropertyName("description")] + public string? Description { get; set; } + + [JsonPropertyName("defaultSubscription")] + public string? DefaultSubscription { get; set; } + + [JsonPropertyName("visibility")] + public string? Visibility { get; set; } + + [JsonPropertyName("createdAt")] + public string? CreatedAt { get; set; } + + [JsonPropertyName("updatedAt")] + public string? UpdatedAt { get; set; } + } + + public class ContactChannelResponse : ContactChannel + { + [JsonPropertyName("event")] + public string? Event { get; set; } + } + + public class ContactChannelListItem + { + [JsonPropertyName("id")] + public string? Id { get; set; } + + [JsonPropertyName("name")] + public string? Name { get; set; } + + [JsonPropertyName("description")] + public string? Description { get; set; } + + [JsonPropertyName("defaultSubscription")] + public string? DefaultSubscription { get; set; } + + [JsonPropertyName("visibility")] + public string? Visibility { get; set; } + + [JsonPropertyName("createdAt")] + public string? CreatedAt { get; set; } + + [JsonPropertyName("updatedAt")] + public string? UpdatedAt { get; set; } + + [JsonPropertyName("subscriberCount")] + public int? SubscriberCount { get; set; } + } + + public class CreateChannelParams + { + [JsonPropertyName("name")] + public string? Name { get; set; } + + [JsonPropertyName("description")] + public string? Description { get; set; } + + [JsonPropertyName("defaultSubscription")] + public string? DefaultSubscription { get; set; } + + [JsonPropertyName("visibility")] + public string? Visibility { get; set; } + } + + public class UpdateChannelParams + { + [JsonPropertyName("name")] + public string? Name { get; set; } + + [JsonPropertyName("description")] + public string? Description { get; set; } + + [JsonPropertyName("visibility")] + public string? Visibility { get; set; } + + [JsonPropertyName("descriptionPresent")] + public bool DescriptionPresent { get; set; } + + [JsonPropertyName("descriptionClear")] + public bool DescriptionClear { get; set; } + } + + public class ListChannelsParams + { + public int? Page { get; set; } + public int? Limit { get; set; } + } + + public class ChannelListResponse + { + [JsonPropertyName("object")] + public string? Object { get; set; } + + [JsonPropertyName("channels")] + public List? Channels { get; set; } + + [JsonPropertyName("total")] + public int Total { get; set; } + + [JsonPropertyName("page")] + public int Page { get; set; } + + [JsonPropertyName("limit")] + public int Limit { get; set; } + + [JsonPropertyName("event")] + public string? Event { get; set; } + } + + public class DeleteChannelResponse + { + [JsonPropertyName("object")] + public string? Object { get; set; } + + [JsonPropertyName("success")] + public bool Success { get; set; } + + [JsonPropertyName("id")] + public string? Id { get; set; } + + [JsonPropertyName("name")] + public string? Name { get; set; } + + [JsonPropertyName("event")] + public string? Event { get; set; } + } + + public class AddContactToChannelParams + { + [JsonPropertyName("contact_id")] + public string? ContactId { get; set; } + + [JsonPropertyName("email")] + public string? Email { get; set; } + + [JsonPropertyName("subscription")] + public string? Subscription { get; set; } + } + + public class UpdateContactChannelParams + { + [JsonPropertyName("contact_id")] + public string? ContactId { get; set; } + + [JsonPropertyName("email")] + public string? Email { get; set; } + + [JsonPropertyName("subscription")] + public string? Subscription { get; set; } + } + + public class AddContactToChannelResponse + { + [JsonPropertyName("contact")] + public ContactResponse? Contact { get; set; } + + [JsonPropertyName("subscriptionId")] + public string? SubscriptionId { get; set; } + + [JsonPropertyName("event")] + public string? Event { get; set; } + } + + public class UpdateContactChannelResponse + { + [JsonPropertyName("success")] + public bool Success { get; set; } + + [JsonPropertyName("status")] + public string? Status { get; set; } + + [JsonPropertyName("event")] + public string? Event { get; set; } + } +} diff --git a/Models/DomainModels.cs b/Models/DomainModels.cs new file mode 100644 index 0000000..2c2519b --- /dev/null +++ b/Models/DomainModels.cs @@ -0,0 +1,215 @@ +using System.Text.Json.Serialization; + +namespace Reloop.Models; + +public static class DomainModels +{ + public static class DomainStatus + { + public const string Pending = "pending"; + public const string Verifying = "verifying"; + public const string Active = "active"; + public const string Suspended = "suspended"; + public const string Failed = "failed"; + } + + public static class DomainTlsMode + { + public const string Opportunistic = "opportunistic"; + public const string Enforced = "enforced"; + } + + public class DnsRecord + { + [JsonPropertyName("id")] + public string? Id { get; set; } + + [JsonPropertyName("recordType")] + public string? RecordType { get; set; } + + [JsonPropertyName("recordTypeName")] + public string? RecordTypeName { get; set; } + + [JsonPropertyName("domain")] + public string? Domain { get; set; } + + [JsonPropertyName("name")] + public string? Name { get; set; } + + [JsonPropertyName("value")] + public string? Value { get; set; } + + [JsonPropertyName("ttl")] + public string? Ttl { get; set; } + + [JsonPropertyName("priority")] + public int? Priority { get; set; } + + [JsonPropertyName("verificationError")] + public string? VerificationError { get; set; } + + [JsonPropertyName("purpose")] + public string? Purpose { get; set; } + + [JsonPropertyName("createdAt")] + public string? CreatedAt { get; set; } + + [JsonPropertyName("status")] + public string? Status { get; set; } + + [JsonPropertyName("updatedAt")] + public string? UpdatedAt { get; set; } + } + + public class Domain + { + [JsonPropertyName("object")] + public string? Object { get; set; } + + [JsonPropertyName("id")] + public string? Id { get; set; } + + [JsonPropertyName("domain")] + public string? DomainName { get; set; } + + [JsonPropertyName("status")] + public string? Status { get; set; } + + [JsonPropertyName("userVerifiedDomain")] + public bool UserVerifiedDomain { get; set; } + + [JsonPropertyName("systemVerified")] + public bool SystemVerified { get; set; } + + [JsonPropertyName("customReturnPath")] + public string? CustomReturnPath { get; set; } + + [JsonPropertyName("trackingSubdomain")] + public string? TrackingSubdomain { get; set; } + + [JsonPropertyName("isClickTrackingEnabled")] + public bool IsClickTrackingEnabled { get; set; } + + [JsonPropertyName("isOpenTrackingEnabled")] + public bool IsOpenTrackingEnabled { get; set; } + + [JsonPropertyName("tls")] + public string? Tls { get; set; } + + [JsonPropertyName("isTrackingDomain")] + public bool IsTrackingDomain { get; set; } + + [JsonPropertyName("isSendingEmailEnabled")] + public bool IsSendingEmailEnabled { get; set; } + + [JsonPropertyName("isReceivingEmailEnabled")] + public bool IsReceivingEmailEnabled { get; set; } + + [JsonPropertyName("verificationFailedReason")] + public string? VerificationFailedReason { get; set; } + + [JsonPropertyName("dnsRecords")] + public List? DnsRecords { get; set; } + + [JsonPropertyName("lastVerifiedAt")] + public string? LastVerifiedAt { get; set; } + + [JsonPropertyName("createdAt")] + public string? CreatedAt { get; set; } + + [JsonPropertyName("updatedAt")] + public string? UpdatedAt { get; set; } + + [JsonPropertyName("event")] + public string? Event { get; set; } + } + + public class CreateDomainParams + { + [JsonPropertyName("domain")] + public string? Domain { get; set; } + + [JsonPropertyName("click_tracking")] + public bool? ClickTracking { get; set; } + + [JsonPropertyName("open_tracking")] + public bool? OpenTracking { get; set; } + + [JsonPropertyName("tls")] + public string? Tls { get; set; } + + [JsonPropertyName("sending_email")] + public bool? SendingEmail { get; set; } + + [JsonPropertyName("receiving_email")] + public bool? ReceivingEmail { get; set; } + + public CreateDomainParams() + { + } + + public CreateDomainParams(string domain) + { + Domain = domain; + } + } + + public class UpdateDomainParams + { + [JsonPropertyName("click_tracking")] + public bool? ClickTracking { get; set; } + + [JsonPropertyName("open_tracking")] + public bool? OpenTracking { get; set; } + + [JsonPropertyName("sending_email")] + public bool? SendingEmail { get; set; } + + [JsonPropertyName("receiving_email")] + public bool? ReceivingEmail { get; set; } + + [JsonPropertyName("tls")] + public string? Tls { get; set; } + } + + public class ListDomainsParams + { + public int? Page { get; set; } + public int? Limit { get; set; } + public string? Q { get; set; } + public string? Status { get; set; } + } + + public class DomainListResponse + { + [JsonPropertyName("object")] + public string? Object { get; set; } + + [JsonPropertyName("domains")] + public List? Domains { get; set; } + + [JsonPropertyName("total")] + public int Total { get; set; } + + [JsonPropertyName("page")] + public int Page { get; set; } + + [JsonPropertyName("limit")] + public int Limit { get; set; } + + [JsonPropertyName("event")] + public string? Event { get; set; } + } + + public class DomainStatusResponse + { + [JsonPropertyName("id")] + public string? Id { get; set; } + + [JsonPropertyName("status")] + public string? Status { get; set; } + + [JsonPropertyName("event")] + public string? Event { get; set; } + } +} diff --git a/Models/InboxModels.cs b/Models/InboxModels.cs new file mode 100644 index 0000000..34f5e1e --- /dev/null +++ b/Models/InboxModels.cs @@ -0,0 +1,709 @@ +using System.Text.Json.Serialization; + +namespace Reloop.Models; + +public static class InboxModels +{ + public static class MailboxStatus + { + public const string Active = "active"; + public const string Disabled = "disabled"; + } + + public static class ThreadStatus + { + public const string Active = "active"; + public const string Archived = "archived"; + public const string Closed = "closed"; + public const string Trash = "trash"; + } + + public static class ThreadFilter + { + public const string Primary = "primary"; + public const string Alerts = "alerts"; + public const string Person = "person"; + public const string Tag = "tag"; + } + + public static class ThreadBatchAction + { + public const string Archive = "archive"; + public const string Trash = "trash"; + public const string Restore = "restore"; + public const string Star = "star"; + public const string Unstar = "unstar"; + public const string Read = "read"; + public const string Unread = "unread"; + public const string Important = "important"; + public const string Unimportant = "unimportant"; + public const string Spam = "spam"; + public const string Unspam = "unspam"; + public const string Pin = "pin"; + public const string Unpin = "unpin"; + } + + public class InboxSuccessResponse + { + [JsonPropertyName("success")] + public bool Success { get; set; } + + [JsonPropertyName("id")] + public string? Id { get; set; } + + [JsonPropertyName("message")] + public string? Message { get; set; } + + [JsonPropertyName("isRead")] + public bool? IsRead { get; set; } + + [JsonPropertyName("isStarred")] + public bool? IsStarred { get; set; } + + [JsonPropertyName("isSpam")] + public bool? IsSpam { get; set; } + + [JsonPropertyName("isImportant")] + public bool? IsImportant { get; set; } + + [JsonPropertyName("isPinned")] + public bool? IsPinned { get; set; } + + [JsonPropertyName("status")] + public string? Status { get; set; } + + [JsonPropertyName("deletedAt")] + public string? DeletedAt { get; set; } + } + + public class SendEmailResponse + { + [JsonPropertyName("success")] + public bool Success { get; set; } + + [JsonPropertyName("messageId")] + public string? MessageId { get; set; } + + [JsonPropertyName("status")] + public string? Status { get; set; } + + [JsonPropertyName("timestamp")] + public string? Timestamp { get; set; } + + [JsonPropertyName("id")] + public string? Id { get; set; } + } + + public class SendEmailOrPendingResponse + { + [JsonPropertyName("success")] + public bool Success { get; set; } + + [JsonPropertyName("messageId")] + public string? MessageId { get; set; } + + [JsonPropertyName("status")] + public string? Status { get; set; } + + [JsonPropertyName("timestamp")] + public string? Timestamp { get; set; } + + [JsonPropertyName("id")] + public string? Id { get; set; } + + [JsonPropertyName("pending")] + public bool? Pending { get; set; } + + [JsonPropertyName("sendAt")] + public string? SendAt { get; set; } + } + + public class ThreadBatchResponse + { + [JsonPropertyName("success")] + public bool Success { get; set; } + + [JsonPropertyName("ids")] + public List? Ids { get; set; } + + [JsonPropertyName("action")] + public string? Action { get; set; } + } + + public class MessageAttachment + { + [JsonPropertyName("id")] + public string? Id { get; set; } + + [JsonPropertyName("filename")] + public string? Filename { get; set; } + + [JsonPropertyName("contentType")] + public string? ContentType { get; set; } + + [JsonPropertyName("size")] + public int Size { get; set; } + + [JsonPropertyName("storagePath")] + public string? StoragePath { get; set; } + + [JsonPropertyName("contentDisposition")] + public string? ContentDisposition { get; set; } + + [JsonPropertyName("contentId")] + public string? ContentId { get; set; } + + [JsonPropertyName("createdAt")] + public string? CreatedAt { get; set; } + } + + public class AttachmentInput + { + [JsonPropertyName("content")] + public string? Content { get; set; } + + [JsonPropertyName("filename")] + public string? Filename { get; set; } + + [JsonPropertyName("path")] + public string? Path { get; set; } + + [JsonPropertyName("content_type")] + public string? ContentType { get; set; } + + [JsonPropertyName("content_id")] + public string? ContentId { get; set; } + } + + public class Mailbox + { + [JsonPropertyName("id")] + public string? Id { get; set; } + + [JsonPropertyName("email")] + public string? Email { get; set; } + + [JsonPropertyName("quota")] + public string? Quota { get; set; } + + [JsonPropertyName("status")] + public string? Status { get; set; } + + [JsonPropertyName("displayName")] + public string? DisplayName { get; set; } + + [JsonPropertyName("createdAt")] + public string? CreatedAt { get; set; } + } + + public class MailboxDetail : Mailbox + { + [JsonPropertyName("domainId")] + public string? DomainId { get; set; } + + [JsonPropertyName("updatedAt")] + public string? UpdatedAt { get; set; } + } + + public class CreateMailboxResponse + { + [JsonPropertyName("id")] + public string? Id { get; set; } + + [JsonPropertyName("email")] + public string? Email { get; set; } + + [JsonPropertyName("status")] + public string? Status { get; set; } + } + + public class CreateMailboxParams + { + [JsonPropertyName("domainId")] + public string? DomainId { get; set; } + + [JsonPropertyName("email")] + public string? Email { get; set; } + + [JsonPropertyName("password")] + public string? Password { get; set; } + + [JsonPropertyName("quota")] + public string? Quota { get; set; } + + [JsonPropertyName("displayName")] + public string? DisplayName { get; set; } + } + + public class UpdateMailboxParams + { + [JsonPropertyName("displayName")] + public string? DisplayName { get; set; } + + [JsonPropertyName("status")] + public string? Status { get; set; } + + [JsonPropertyName("quota")] + public string? Quota { get; set; } + } + + public class MessageAttachmentItem + { + [JsonPropertyName("id")] + public string? Id { get; set; } + + [JsonPropertyName("inboundEmailId")] + public string? InboundEmailId { get; set; } + + [JsonPropertyName("filename")] + public string? Filename { get; set; } + + [JsonPropertyName("contentType")] + public string? ContentType { get; set; } + + [JsonPropertyName("size")] + public int Size { get; set; } + + [JsonPropertyName("storagePath")] + public string? StoragePath { get; set; } + + [JsonPropertyName("contentDisposition")] + public string? ContentDisposition { get; set; } + + [JsonPropertyName("contentId")] + public string? ContentId { get; set; } + + [JsonPropertyName("createdAt")] + public string? CreatedAt { get; set; } + } + + public class Message + { + [JsonPropertyName("id")] + public string? Id { get; set; } + + [JsonPropertyName("mailboxId")] + public string? MailboxId { get; set; } + + [JsonPropertyName("organizationId")] + public string? OrganizationId { get; set; } + + [JsonPropertyName("fromEmail")] + public string? FromEmail { get; set; } + + [JsonPropertyName("fromName")] + public string? FromName { get; set; } + + [JsonPropertyName("toEmails")] + public List? ToEmails { get; set; } + + [JsonPropertyName("ccEmails")] + public List? CcEmails { get; set; } + + [JsonPropertyName("bccEmails")] + public List? BccEmails { get; set; } + + [JsonPropertyName("replyTo")] + public string? ReplyTo { get; set; } + + [JsonPropertyName("subject")] + public string? Subject { get; set; } + + [JsonPropertyName("textBody")] + public string? TextBody { get; set; } + + [JsonPropertyName("htmlBody")] + public string? HtmlBody { get; set; } + + [JsonPropertyName("snippet")] + public string? Snippet { get; set; } + + [JsonPropertyName("size")] + public int Size { get; set; } + + [JsonPropertyName("status")] + public string? Status { get; set; } + + [JsonPropertyName("isRead")] + public bool IsRead { get; set; } + + [JsonPropertyName("isStarred")] + public bool IsStarred { get; set; } + + [JsonPropertyName("isSpam")] + public bool IsSpam { get; set; } + + [JsonPropertyName("spamScore")] + public double? SpamScore { get; set; } + + [JsonPropertyName("messageId")] + public string? MessageId { get; set; } + + [JsonPropertyName("threadId")] + public string? ThreadId { get; set; } + + [JsonPropertyName("inReplyTo")] + public string? InReplyTo { get; set; } + + [JsonPropertyName("references")] + public List? References { get; set; } + + [JsonPropertyName("headers")] + public Dictionary? Headers { get; set; } + + [JsonPropertyName("date")] + public string? Date { get; set; } + + [JsonPropertyName("createdAt")] + public string? CreatedAt { get; set; } + + [JsonPropertyName("attachments")] + public List? Attachments { get; set; } + } + + public class MessageRaw + { + [JsonPropertyName("id")] + public string? Id { get; set; } + + [JsonPropertyName("messageId")] + public string? MessageId { get; set; } + + [JsonPropertyName("raw")] + public string? Raw { get; set; } + } + + public class ListMessagesParams + { + [JsonPropertyName("mailboxId")] + public string? MailboxId { get; set; } + + [JsonPropertyName("limit")] + public int? Limit { get; set; } + + [JsonPropertyName("offset")] + public int? Offset { get; set; } + + [JsonPropertyName("q")] + public string? Q { get; set; } + + [JsonPropertyName("isSpam")] + public bool? IsSpam { get; set; } + } + + public class ListSentMessagesParams + { + [JsonPropertyName("mailboxId")] + public string? MailboxId { get; set; } + } + + public class BatchMessagesParams + { + [JsonPropertyName("ids")] + public List? Ids { get; set; } + } + + public class UpdateMessageParams + { + [JsonPropertyName("isRead")] + public bool? IsRead { get; set; } + + [JsonPropertyName("isStarred")] + public bool? IsStarred { get; set; } + + [JsonPropertyName("isSpam")] + public bool? IsSpam { get; set; } + } + + public class SetMessageReadParams + { + [JsonPropertyName("isRead")] + public bool IsRead { get; set; } + + public SetMessageReadParams() + { + IsRead = true; + } + + public SetMessageReadParams(bool isRead) + { + IsRead = isRead; + } + } + + public class SetMessageStarParams + { + [JsonPropertyName("isStarred")] + public bool IsStarred { get; set; } + + public SetMessageStarParams() + { + IsStarred = true; + } + + public SetMessageStarParams(bool isStarred) + { + IsStarred = isStarred; + } + } + + public class SendMessageParams + { + [JsonPropertyName("mailboxId")] + public string? MailboxId { get; set; } + + [JsonPropertyName("to")] + public object? To { get; set; } + + [JsonPropertyName("subject")] + public string? Subject { get; set; } + + [JsonPropertyName("text")] + public string? Text { get; set; } + + [JsonPropertyName("html")] + public string? Html { get; set; } + + [JsonPropertyName("cc")] + public object? Cc { get; set; } + + [JsonPropertyName("bcc")] + public object? Bcc { get; set; } + + [JsonPropertyName("attachments")] + public List? Attachments { get; set; } + + [JsonPropertyName("scheduledAt")] + public string? ScheduledAt { get; set; } + + [JsonPropertyName("undoWindowSeconds")] + public double? UndoWindowSeconds { get; set; } + } + + public class ComposeMessageParams + { + [JsonPropertyName("text")] + public string? Text { get; set; } + + [JsonPropertyName("html")] + public string? Html { get; set; } + + [JsonPropertyName("cc")] + public object? Cc { get; set; } + + [JsonPropertyName("bcc")] + public object? Bcc { get; set; } + + [JsonPropertyName("attachments")] + public List? Attachments { get; set; } + } + + public class ForwardMessageParams : ComposeMessageParams + { + [JsonPropertyName("to")] + public object? To { get; set; } + } + + public class ThreadLabel + { + [JsonPropertyName("id")] + public string? Id { get; set; } + + [JsonPropertyName("name")] + public string? Name { get; set; } + + [JsonPropertyName("color")] + public string? Color { get; set; } + } + + public class Thread + { + [JsonPropertyName("id")] + public string? Id { get; set; } + + [JsonPropertyName("mailboxId")] + public string? MailboxId { get; set; } + + [JsonPropertyName("organizationId")] + public string? OrganizationId { get; set; } + + [JsonPropertyName("subject")] + public string? Subject { get; set; } + + [JsonPropertyName("lastMessagePreview")] + public string? LastMessagePreview { get; set; } + + [JsonPropertyName("lastMessageAt")] + public string? LastMessageAt { get; set; } + + [JsonPropertyName("status")] + public string? Status { get; set; } + + [JsonPropertyName("messageCount")] + public int MessageCount { get; set; } + + [JsonPropertyName("participants")] + public List? Participants { get; set; } + + [JsonPropertyName("isRead")] + public bool IsRead { get; set; } + + [JsonPropertyName("isStarred")] + public bool IsStarred { get; set; } + + [JsonPropertyName("isImportant")] + public bool? IsImportant { get; set; } + + [JsonPropertyName("isPinned")] + public bool? IsPinned { get; set; } + + [JsonPropertyName("pinnedAt")] + public string? PinnedAt { get; set; } + + [JsonPropertyName("labels")] + public List? Labels { get; set; } + + [JsonPropertyName("deletedAt")] + public string? DeletedAt { get; set; } + + [JsonPropertyName("createdAt")] + public string? CreatedAt { get; set; } + + [JsonPropertyName("updatedAt")] + public string? UpdatedAt { get; set; } + } + + public class ThreadMessage + { + [JsonPropertyName("id")] + public string? Id { get; set; } + + [JsonPropertyName("threadId")] + public string? ThreadId { get; set; } + + [JsonPropertyName("direction")] + public string? Direction { get; set; } + + [JsonPropertyName("inboundEmailId")] + public string? InboundEmailId { get; set; } + + [JsonPropertyName("emailLogId")] + public string? EmailLogId { get; set; } + + [JsonPropertyName("fromEmail")] + public string? FromEmail { get; set; } + + [JsonPropertyName("fromName")] + public string? FromName { get; set; } + + [JsonPropertyName("subject")] + public string? Subject { get; set; } + + [JsonPropertyName("preview")] + public string? Preview { get; set; } + + [JsonPropertyName("messageAt")] + public string? MessageAt { get; set; } + + [JsonPropertyName("rfc822MessageId")] + public string? Rfc822MessageId { get; set; } + + [JsonPropertyName("inReplyTo")] + public string? InReplyTo { get; set; } + + [JsonPropertyName("createdAt")] + public string? CreatedAt { get; set; } + + [JsonPropertyName("email")] + public object? Email { get; set; } + } + + public class ThreadDetail : Thread + { + [JsonPropertyName("messages")] + public List? Messages { get; set; } + } + + public class ListThreadsParams + { + [JsonPropertyName("mailboxId")] + public string? MailboxId { get; set; } + + [JsonPropertyName("limit")] + public int? Limit { get; set; } + + [JsonPropertyName("offset")] + public int? Offset { get; set; } + + [JsonPropertyName("folder")] + public string? Folder { get; set; } + + [JsonPropertyName("q")] + public string? Q { get; set; } + + [JsonPropertyName("isPinned")] + public bool? IsPinned { get; set; } + + [JsonPropertyName("filter")] + public string? Filter { get; set; } + } + + public class BatchThreadsParams + { + [JsonPropertyName("ids")] + public List? Ids { get; set; } + + [JsonPropertyName("action")] + public string? Action { get; set; } + } + + public class UpdateThreadParams + { + [JsonPropertyName("isRead")] + public bool? IsRead { get; set; } + + [JsonPropertyName("isStarred")] + public bool? IsStarred { get; set; } + + [JsonPropertyName("isImportant")] + public bool? IsImportant { get; set; } + + [JsonPropertyName("isPinned")] + public bool? IsPinned { get; set; } + + [JsonPropertyName("status")] + public string? Status { get; set; } + } + + public class SetThreadReadParams + { + [JsonPropertyName("isRead")] + public bool IsRead { get; set; } + + public SetThreadReadParams() + { + IsRead = true; + } + + public SetThreadReadParams(bool isRead) + { + IsRead = isRead; + } + } + + public class SetThreadStarParams + { + [JsonPropertyName("isStarred")] + public bool IsStarred { get; set; } + + public SetThreadStarParams() + { + IsStarred = true; + } + + public SetThreadStarParams(bool isStarred) + { + IsStarred = isStarred; + } + } +} diff --git a/Models/MailModels.cs b/Models/MailModels.cs new file mode 100644 index 0000000..43ec5d9 --- /dev/null +++ b/Models/MailModels.cs @@ -0,0 +1,118 @@ +using System.Text.Json.Serialization; + +namespace Reloop.Models; + +public static class MailModels +{ + public class SendMailTag + { + [JsonPropertyName("name")] + public string? Name { get; set; } + + [JsonPropertyName("value")] + public string? Value { get; set; } + + public SendMailTag() + { + } + + public SendMailTag(string name, string value) + { + Name = name; + Value = value; + } + } + + public class SendMailAttachment + { + [JsonPropertyName("content")] + public object? Content { get; set; } + + [JsonPropertyName("filename")] + public string? Filename { get; set; } + + [JsonPropertyName("path")] + public string? Path { get; set; } + + [JsonPropertyName("content_type")] + public string? ContentType { get; set; } + + [JsonPropertyName("content_id")] + public string? ContentId { get; set; } + } + + public class SendMailTemplate + { + [JsonPropertyName("id")] + public string? Id { get; set; } + + [JsonPropertyName("variables")] + public Dictionary? Variables { get; set; } + } + + public class SendMailParams + { + [JsonPropertyName("from")] + public string? From { get; set; } + + [JsonPropertyName("to")] + public object? To { get; set; } + + [JsonPropertyName("subject")] + public string? Subject { get; set; } + + [JsonPropertyName("cc")] + public object? Cc { get; set; } + + [JsonPropertyName("bcc")] + public object? Bcc { get; set; } + + [JsonPropertyName("text")] + public string? Text { get; set; } + + [JsonPropertyName("html")] + public string? Html { get; set; } + + [JsonPropertyName("reply_to")] + public object? ReplyTo { get; set; } + + [JsonPropertyName("scheduled_at")] + public string? ScheduledAt { get; set; } + + [JsonPropertyName("headers")] + public Dictionary? Headers { get; set; } + + [JsonPropertyName("channel_id")] + public string? ChannelId { get; set; } + + [JsonPropertyName("attachments")] + public List? Attachments { get; set; } + + [JsonPropertyName("tags")] + public List? Tags { get; set; } + + [JsonPropertyName("template")] + public SendMailTemplate? Template { get; set; } + + [JsonPropertyName("thread_id")] + public string? ThreadId { get; set; } + } + + public class SendMailResponse + { + [JsonPropertyName("success")] + public bool Success { get; set; } + + [JsonPropertyName("messageId")] + public string? MessageId { get; set; } + + [JsonPropertyName("status")] + public string? Status { get; set; } + + [JsonPropertyName("timestamp")] + public string? Timestamp { get; set; } + + [JsonPropertyName("id")] + public string? Id { get; set; } + } +} diff --git a/Models/Models.cs b/Models/Models.cs deleted file mode 100644 index b9db5e7..0000000 --- a/Models/Models.cs +++ /dev/null @@ -1,194 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Text.Json.Serialization; - -namespace Reloop.Models -{ - public record User( - [property: JsonPropertyName("id")] string Id, - [property: JsonPropertyName("name")] string? Name, - [property: JsonPropertyName("image")] string? Image, - [property: JsonPropertyName("email")] string Email - ); - - public record ApiKey( - [property: JsonPropertyName("id")] string Id, - [property: JsonPropertyName("name")] string? Name, - [property: JsonPropertyName("start")] string? Start, - [property: JsonPropertyName("prefix")] string? Prefix, - [property: JsonPropertyName("organizationId")] string OrganizationId, - [property: JsonPropertyName("userId")] string UserId, - [property: JsonPropertyName("refillInterval")] int? RefillInterval, - [property: JsonPropertyName("refillAmount")] int? RefillAmount, - [property: JsonPropertyName("lastRefillAt")] string? LastRefillAt, - [property: JsonPropertyName("enabled")] bool Enabled, - [property: JsonPropertyName("rateLimitEnabled")] bool RateLimitEnabled, - [property: JsonPropertyName("rateLimitTimeWindow")] int RateLimitTimeWindow, - [property: JsonPropertyName("rateLimitMax")] int RateLimitMax, - [property: JsonPropertyName("requestCount")] int RequestCount, - [property: JsonPropertyName("remaining")] int? Remaining, - [property: JsonPropertyName("lastRequest")] string? LastRequest, - [property: JsonPropertyName("expiresAt")] string? ExpiresAt, - [property: JsonPropertyName("createdAt")] string CreatedAt, - [property: JsonPropertyName("updatedAt")] string UpdatedAt, - [property: JsonPropertyName("permissions")] string? Permissions, - [property: JsonPropertyName("metadata")] string? Metadata, - [property: JsonPropertyName("createdBy")] User? CreatedBy, - [property: JsonPropertyName("object")] string Object, - [property: JsonPropertyName("event")] string Event - ); - - public record ApiKeyWithKey( - [property: JsonPropertyName("id")] string Id, - [property: JsonPropertyName("name")] string? Name, - [property: JsonPropertyName("key")] string Key, - [property: JsonPropertyName("enabled")] bool Enabled, - [property: JsonPropertyName("createdAt")] string CreatedAt, - [property: JsonPropertyName("updatedAt")] string UpdatedAt, - [property: JsonPropertyName("permissions")] string? Permissions, - [property: JsonPropertyName("object")] string Object, - [property: JsonPropertyName("event")] string Event - ); - - public record ApiKeyListResponse( - [property: JsonPropertyName("object")] string Object, - [property: JsonPropertyName("apiKeys")] List ApiKeys, - [property: JsonPropertyName("total")] int Total, - [property: JsonPropertyName("page")] int Page, - [property: JsonPropertyName("limit")] int Limit, - [property: JsonPropertyName("event")] string Event - ); - - public class ApiKeyListParams - { - public int? Page { get; set; } - public int? Limit { get; set; } - public bool? Enabled { get; set; } - public string? UserId { get; set; } - public string? Q { get; set; } - } - - public record DeleteApiKeyResponse( - [property: JsonPropertyName("id")] string Id, - [property: JsonPropertyName("message")] string Message, - [property: JsonPropertyName("object")] string Object, - [property: JsonPropertyName("event")] string Event - ); - - public record CreateApiKeyParams( - [property: JsonPropertyName("name")] string Name, - [property: JsonPropertyName("enabled")] bool? Enabled = null, - [property: JsonPropertyName("rateLimitEnabled")] bool? RateLimitEnabled = null - ); - - public record UpdateApiKeyParams( - [property: JsonPropertyName("name")] string? Name = null, - [property: JsonPropertyName("enabled")] bool? Enabled = null - ); - - public record DnsRecord( - [property: JsonPropertyName("id")] string Id, - [property: JsonPropertyName("recordType")] string RecordType, - [property: JsonPropertyName("recordTypeName")] string RecordTypeName, - [property: JsonPropertyName("domain")] string Domain, - [property: JsonPropertyName("name")] string Name, - [property: JsonPropertyName("value")] string Value, - [property: JsonPropertyName("ttl")] string Ttl, - [property: JsonPropertyName("priority")] int? Priority, - [property: JsonPropertyName("verificationError")] string? VerificationError, - [property: JsonPropertyName("purpose")] string? Purpose, - [property: JsonPropertyName("createdAt")] string CreatedAt, - [property: JsonPropertyName("status")] string Status, - [property: JsonPropertyName("updatedAt")] string UpdatedAt - ); - - public record Domain( - [property: JsonPropertyName("object")] string Object, - [property: JsonPropertyName("id")] string Id, - [property: JsonPropertyName("domain")] string DomainName, - [property: JsonPropertyName("status")] string Status, - [property: JsonPropertyName("userVerifiedDomain")] bool UserVerifiedDomain, - [property: JsonPropertyName("systemVerified")] bool SystemVerified, - [property: JsonPropertyName("customReturnPath")] string CustomReturnPath, - [property: JsonPropertyName("trackingSubdomain")] string TrackingSubdomain, - [property: JsonPropertyName("isClickTrackingEnabled")] bool IsClickTrackingEnabled, - [property: JsonPropertyName("isOpenTrackingEnabled")] bool IsOpenTrackingEnabled, - [property: JsonPropertyName("tls")] string Tls, - [property: JsonPropertyName("isTrackingDomain")] bool IsTrackingDomain, - [property: JsonPropertyName("isSendingEmailEnabled")] bool IsSendingEmailEnabled, - [property: JsonPropertyName("isReceivingEmailEnabled")] bool IsReceivingEmailEnabled, - [property: JsonPropertyName("verificationFailedReason")] string? VerificationFailedReason, - [property: JsonPropertyName("dnsRecords")] List DnsRecords, - [property: JsonPropertyName("lastVerifiedAt")] string? LastVerifiedAt, - [property: JsonPropertyName("createdAt")] string CreatedAt, - [property: JsonPropertyName("updatedAt")] string UpdatedAt, - [property: JsonPropertyName("event")] string? Event - ); - - public record CreateDomainParams( - [property: JsonPropertyName("domain")] string Domain, - [property: JsonPropertyName("custom_return_path")] string? CustomReturnPath = null, - [property: JsonPropertyName("tracking")] string? Tracking = null, - [property: JsonPropertyName("click_tracking")] bool? ClickTracking = null, - [property: JsonPropertyName("open_tracking")] bool? OpenTracking = null, - [property: JsonPropertyName("tls")] string? Tls = null, - [property: JsonPropertyName("sending_email")] bool? SendingEmail = null, - [property: JsonPropertyName("receiving_email")] bool? ReceivingEmail = null - ); - - public record UpdateDomainParams( - [property: JsonPropertyName("click_tracking")] bool? ClickTracking = null, - [property: JsonPropertyName("open_tracking")] bool? OpenTracking = null, - [property: JsonPropertyName("sending_email")] bool? SendingEmail = null, - [property: JsonPropertyName("receiving_email")] bool? ReceivingEmail = null, - [property: JsonPropertyName("tls")] string? Tls = null - ); - - public class ListDomainsParams - { - public int? Page { get; set; } - public int? Limit { get; set; } - public string? Q { get; set; } - public string? Status { get; set; } - } - - public record DomainListResponse( - [property: JsonPropertyName("object")] string Object, - [property: JsonPropertyName("domains")] List Domains, - [property: JsonPropertyName("total")] int Total, - [property: JsonPropertyName("page")] int Page, - [property: JsonPropertyName("limit")] int Limit, - [property: JsonPropertyName("event")] string Event - ); - - public record DomainStatusResponse( - [property: JsonPropertyName("id")] string Id, - [property: JsonPropertyName("status")] string Status, - [property: JsonPropertyName("event")] string? Event - ); - - public record ForwardDnsParams( - [property: JsonPropertyName("email")] string Email - ); - - public record ForwardDnsResponse( - [property: JsonPropertyName("success")] bool Success - ); - - public record DomainNameserversResponse( - [property: JsonPropertyName("object")] string Object, - [property: JsonPropertyName("domainId")] string DomainId, - [property: JsonPropertyName("domain")] string Domain, - [property: JsonPropertyName("nameservers")] List? Nameservers, - [property: JsonPropertyName("dnsProvider")] string? DnsProvider, - [property: JsonPropertyName("event")] string Event - ); - - public record SendMailResponse( - [property: JsonPropertyName("success")] bool Success, - [property: JsonPropertyName("messageId")] string MessageId, - [property: JsonPropertyName("status")] string Status, - [property: JsonPropertyName("timestamp")] string Timestamp, - [property: JsonPropertyName("id")] string Id - ); -} diff --git a/Models/WebhookModels.cs b/Models/WebhookModels.cs new file mode 100644 index 0000000..dbd5ac4 --- /dev/null +++ b/Models/WebhookModels.cs @@ -0,0 +1,339 @@ +using System.Text.Json.Serialization; + +namespace Reloop.Models; + +public static class WebhookModels +{ + public static class WebhookStatus + { + public const string Active = "active"; + public const string Paused = "paused"; + public const string Disabled = "disabled"; + public const string Failed = "failed"; + } + + public static class WebhookDeliveryStatus + { + public const string Pending = "pending"; + public const string Success = "success"; + public const string Failed = "failed"; + public const string Retrying = "retrying"; + } + + public class Webhook + { + [JsonPropertyName("id")] + public string? Id { get; set; } + + [JsonPropertyName("name")] + public string? Name { get; set; } + + [JsonPropertyName("url")] + public string? Url { get; set; } + + [JsonPropertyName("secret")] + public string? Secret { get; set; } + + [JsonPropertyName("status")] + public string? Status { get; set; } + + [JsonPropertyName("customHeaders")] + public Dictionary? CustomHeaders { get; set; } + + [JsonPropertyName("rateLimitEnabled")] + public bool RateLimitEnabled { get; set; } + + [JsonPropertyName("maxRequestsPerMinute")] + public int MaxRequestsPerMinute { get; set; } + + [JsonPropertyName("maxRetries")] + public int MaxRetries { get; set; } + + [JsonPropertyName("retryBackoffMultiplier")] + public double RetryBackoffMultiplier { get; set; } + + [JsonPropertyName("filteringOptions")] + public Dictionary? FilteringOptions { get; set; } + + [JsonPropertyName("lastTriggeredAt")] + public string? LastTriggeredAt { get; set; } + + [JsonPropertyName("successCount")] + public int SuccessCount { get; set; } + + [JsonPropertyName("failureCount")] + public int FailureCount { get; set; } + + [JsonPropertyName("consecutiveFailures")] + public int ConsecutiveFailures { get; set; } + + [JsonPropertyName("events")] + public List? Events { get; set; } + + [JsonPropertyName("createdAt")] + public string? CreatedAt { get; set; } + + [JsonPropertyName("updatedAt")] + public string? UpdatedAt { get; set; } + } + + public class CreateWebhookParams + { + [JsonPropertyName("description")] + public string? Description { get; set; } + + [JsonPropertyName("url")] + public string? Url { get; set; } + + [JsonPropertyName("events")] + public List? Events { get; set; } + + public CreateWebhookParams() + { + } + + public CreateWebhookParams(string description, string url, List events) + { + Description = description; + Url = url; + Events = events; + } + } + + public class UpdateWebhookParams + { + [JsonPropertyName("description")] + public string? Description { get; set; } + + [JsonPropertyName("name")] + public string? Name { get; set; } + + [JsonPropertyName("url")] + public string? Url { get; set; } + + [JsonPropertyName("secret")] + public string? Secret { get; set; } + + [JsonPropertyName("status")] + public string? Status { get; set; } + + [JsonPropertyName("customHeaders")] + public Dictionary? CustomHeaders { get; set; } + + [JsonPropertyName("rateLimitEnabled")] + public bool? RateLimitEnabled { get; set; } + + [JsonPropertyName("maxRequestsPerMinute")] + public int? MaxRequestsPerMinute { get; set; } + + [JsonPropertyName("maxRetries")] + public int? MaxRetries { get; set; } + + [JsonPropertyName("retryBackoffMultiplier")] + public double? RetryBackoffMultiplier { get; set; } + + [JsonPropertyName("filteringOptions")] + public Dictionary? FilteringOptions { get; set; } + } + + public class ListWebhooksParams + { + public int? Page { get; set; } + public int? Limit { get; set; } + public string? Status { get; set; } + public string? OrganizationId { get; set; } + public string? UserId { get; set; } + } + + public class WebhookListResponse + { + [JsonPropertyName("webhooks")] + public List? Webhooks { get; set; } + + [JsonPropertyName("total")] + public int Total { get; set; } + + [JsonPropertyName("page")] + public int Page { get; set; } + + [JsonPropertyName("limit")] + public int Limit { get; set; } + } + + public class DeleteWebhookResponse + { + [JsonPropertyName("id")] + public string? Id { get; set; } + + [JsonPropertyName("message")] + public string? Message { get; set; } + } + + public class TriggerWebhookParams + { + [JsonPropertyName("event")] + public string? Event { get; set; } + + [JsonPropertyName("payload")] + public Dictionary? Payload { get; set; } + + [JsonPropertyName("organizationId")] + public string? OrganizationId { get; set; } + + [JsonPropertyName("userId")] + public string? UserId { get; set; } + + public TriggerWebhookParams() + { + } + + public TriggerWebhookParams(string @event, Dictionary payload) + { + Event = @event; + Payload = payload; + } + } + + public class TriggerWebhookResponse + { + [JsonPropertyName("success")] + public bool Success { get; set; } + + [JsonPropertyName("message")] + public string? Message { get; set; } + + [JsonPropertyName("jobId")] + public string? JobId { get; set; } + } + + public class WebhookDelivery + { + [JsonPropertyName("id")] + public string? Id { get; set; } + + [JsonPropertyName("webhookId")] + public string? WebhookId { get; set; } + + [JsonPropertyName("webhookEventId")] + public string? WebhookEventId { get; set; } + + [JsonPropertyName("eventType")] + public string? EventType { get; set; } + + [JsonPropertyName("eventData")] + public Dictionary? EventData { get; set; } + + [JsonPropertyName("status")] + public string? Status { get; set; } + + [JsonPropertyName("requestUrl")] + public string? RequestUrl { get; set; } + + [JsonPropertyName("requestHeaders")] + public Dictionary? RequestHeaders { get; set; } + + [JsonPropertyName("requestBody")] + public Dictionary? RequestBody { get; set; } + + [JsonPropertyName("responseStatus")] + public int? ResponseStatus { get; set; } + + [JsonPropertyName("responseBody")] + public string? ResponseBody { get; set; } + + [JsonPropertyName("responseHeaders")] + public Dictionary? ResponseHeaders { get; set; } + + [JsonPropertyName("attemptNumber")] + public int AttemptNumber { get; set; } + + [JsonPropertyName("maxAttempts")] + public int MaxAttempts { get; set; } + + [JsonPropertyName("nextRetryAt")] + public string? NextRetryAt { get; set; } + + [JsonPropertyName("lastAttemptAt")] + public string? LastAttemptAt { get; set; } + + [JsonPropertyName("errorMessage")] + public string? ErrorMessage { get; set; } + + [JsonPropertyName("errorDetails")] + public Dictionary? ErrorDetails { get; set; } + + [JsonPropertyName("completedAt")] + public string? CompletedAt { get; set; } + + [JsonPropertyName("durationMs")] + public int? DurationMs { get; set; } + + [JsonPropertyName("createdAt")] + public string? CreatedAt { get; set; } + } + + public class ListWebhookDeliveriesParams + { + public int? Page { get; set; } + public int? Limit { get; set; } + public string? Status { get; set; } + } + + public class WebhookDeliveryListResponse + { + [JsonPropertyName("deliveries")] + public List? Deliveries { get; set; } + + [JsonPropertyName("total")] + public int Total { get; set; } + + [JsonPropertyName("page")] + public int Page { get; set; } + + [JsonPropertyName("limit")] + public int Limit { get; set; } + } + + public class RetryWebhookDeliveryResponse + { + [JsonPropertyName("success")] + public bool Success { get; set; } + + [JsonPropertyName("message")] + public string? Message { get; set; } + } + + public class WebhookEvent + { + [JsonPropertyName("id")] + public string? Id { get; set; } + + [JsonPropertyName("event")] + public string? Event { get; set; } + + [JsonPropertyName("payload")] + public Dictionary? Payload { get; set; } + + [JsonPropertyName("timestamp")] + public double Timestamp { get; set; } + } + + public class VerifyWebhookParams + { + public byte[]? Payload { get; set; } + public Dictionary? Headers { get; set; } + public string? Secret { get; set; } + public int? Tolerance { get; set; } + + public VerifyWebhookParams() + { + } + + public VerifyWebhookParams(byte[] payload, Dictionary headers, string secret) + { + Payload = payload; + Headers = headers; + Secret = secret; + } + } +} diff --git a/README.md b/README.md index b563586..544b40a 100644 --- a/README.md +++ b/README.md @@ -1,39 +1,175 @@ # Reloop .NET SDK -## Before you send +Official .NET client for the [Reloop](https://reloop.sh) API (package **`Reloop`**, version **2.0.0**). -You need two things: +## Before you send 1. **API key** — create one in your Reloop account 2. **Verified domain** — add and verify a sending domain; use it in the `from` address -For setup details and the full API reference, see [reloop.sh/docs](https://reloop.sh/docs). +Full API reference: [reloop.sh/docs](https://reloop.sh/docs) -## Send email +## Install ```bash -dotnet add package Reloop +dotnet add package Reloop --version 2.0.0 ``` +## Quick start + ```csharp using Reloop; -using System.Collections.Generic; +using Reloop.Exceptions; +using Reloop.Models; +using static Reloop.Models.MailModels; + +var client = new ReloopClient("rl_your_api_key_here"); + +try +{ + var result = await client.Mail.SendAsync(new SendMailParams + { + From = "Reloop ", + To = "user@example.com", + Subject = "Welcome to Reloop", + Html = "

Thanks for signing up.

", + Text = "Thanks for signing up.", + }); + + Console.WriteLine($"{result?.MessageId} {result?.Id}"); +} +catch (ReloopValidationException ex) +{ + Console.Error.WriteLine($"invalid request ({ex.Field}): {ex.Message}"); +} +catch (ReloopApiException ex) +{ + Console.Error.WriteLine($"API error {ex.Status}: {ex.Message}"); +} +``` + +## Services + +```csharp +client.ApiKey; // CreateAsync, ListAsync, GetAsync, UpdateAsync, DeleteAsync, RotateAsync, EnableAsync, DisableAsync +client.Mail; // SendAsync +client.Domain; // CreateAsync, ListAsync, GetAsync, UpdateAsync, DeleteAsync, VerifyAsync +client.Contacts; // CRUD + .Properties, .Groups, .Channels +client.Webhook; // CRUD, PauseAsync/EnableAsync/DisableAsync, TriggerAsync, deliveries + Verify +client.Inbox; // .Mailboxes, .Messages, .Threads +``` + +## Errors + +| Kind | Type | When | +|------|------|------| +| Bad client args | `ReloopValidationException` | Invalid params — **no HTTP call** | +| HTTP / network | `ReloopApiException` | Non-2xx response or transport failure | +| Webhook HMAC | `WebhookSignatureException` | Local signature verification failed | + +Idiomatic .NET exceptions — the equivalent of Node/Python Result objects and Go `(T, error)`. + +## Examples + +### Mail + +```csharp +using static Reloop.Models.MailModels; + +await client.Mail.SendAsync(new SendMailParams +{ + From = "hello@your-verified-domain.com", + To = new[] { "a@example.com", "b@example.com" }, + Subject = "Hello", + Html = "

Hi

", +}); +``` + +### API key + +```csharp +using static Reloop.Models.ApiKeyModels; + +var created = await client.ApiKey.CreateAsync(new CreateApiKeyParams("Production")); +var keys = await client.ApiKey.ListAsync(new ApiKeyListParams { Page = 1, Limit = 20 }); +``` -var reloop = new ReloopClient("rl_your_api_key_here"); +### Domain -var result = await reloop.Mail.SendAsync(new Dictionary +```csharp +using static Reloop.Models.DomainModels; + +var domain = await client.Domain.CreateAsync(new CreateDomainParams("send.example.com") +{ + ClickTracking = true, +}); +await client.Domain.VerifyAsync(domain!.Id!); +``` + +### Contacts + +```csharp +using static Reloop.Models.ContactModels; + +var contact = await client.Contacts.CreateAsync(new CreateContactParams +{ + Email = "user@example.com", + FirstName = "Ada", + Status = ContactStatus.Subscribed, +}); + +await client.Contacts.Properties.CreateAsync(new CreatePropertyParams +{ + Name = "company", + Type = PropertyType.String, +}); +``` + +### Webhook verification + +```csharp +using Reloop.Services; +using static Reloop.Models.WebhookModels; + +var webhookEvent = WebhookService.ConstructEvent(payloadBytes, signatureHeader, secret, tolerance: 300); +// or +var verified = client.Webhook.Verify(new VerifyWebhookParams { - ["from"] = "Reloop ", - ["to"] = "user@example.com", - ["subject"] = "Welcome to Reloop", - ["html"] = "

Thanks for signing up.

", - ["text"] = "Thanks for signing up.", + Payload = payloadBytes, + Headers = headers, + Secret = secret, }); +``` + +### Inbox + +```csharp +using static Reloop.Models.InboxModels; -Console.WriteLine($"{result?.MessageId} {result?.Id}"); +var mailbox = await client.Inbox.Mailboxes.CreateAsync(new CreateMailboxParams +{ + DomainId = "dom_1", + Email = "user@example.com", +}); + +await client.Inbox.Messages.SendAsync(new SendMessageParams +{ + MailboxId = mailbox!.Id!, + To = "recipient@example.com", + Subject = "Hello", + Text = "Hi there", +}); ``` -More examples and optional fields: [reloop.sh/docs](https://reloop.sh/docs) +## Breaking changes from 0.1.0 + +- **`ApiKeys` → `ApiKey`** on `ReloopClient` +- **Typed params/responses** instead of `Dictionary` for mail and contacts +- **Removed:** API key `PauseAsync`, domain `GetNameserversAsync` / `ForwardDnsAsync` +- **Added:** `Webhook`, `Inbox` (mailboxes / messages / threads), nested `Contacts.Properties` +- **Exceptions:** `ReloopException` replaced by `ReloopValidationException`, `ReloopApiException`, `WebhookSignatureException` + +See [CHANGELOG.md](./CHANGELOG.md) for details. ## License diff --git a/Reloop.csproj b/Reloop.csproj index 2bd8bc5..d44c9f7 100644 --- a/Reloop.csproj +++ b/Reloop.csproj @@ -8,8 +8,8 @@ Reloop SDK Reloop Labs Reloop .NET SDK - 0.1.0 - 0.1.0 + 2.0.0 + 2.0.0 Apache-2.0 @@ -18,7 +18,11 @@ - + + + + + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/ReloopClient.cs b/ReloopClient.cs index a3125a3..fb7ac9d 100644 --- a/ReloopClient.cs +++ b/ReloopClient.cs @@ -1,94 +1,212 @@ -using System; +using System.Collections; using System.Net.Http; using System.Net.Http.Headers; using System.Text; using System.Text.Json; using System.Text.Json.Serialization; -using System.Threading.Tasks; +using Reloop.Exceptions; using Reloop.Services; -namespace Reloop +namespace Reloop; + +public class ReloopClient : IDisposable { - public class ReloopException : Exception - { - public ReloopException(string message) : base(message) { } - public ReloopException(string message, Exception innerException) : base(message, innerException) { } - } + private const string DefaultBaseUrl = "https://reloop.sh"; + + private readonly string _apiKey; + private readonly string _baseUrl; + private readonly HttpClient _httpClient; + private readonly bool _ownsHttpClient; + private readonly JsonSerializerOptions _jsonOptions; + private readonly JsonSerializerOptions _jsonOptionsIncludeNull; + + public ApiKeyService ApiKey { get; } + public ContactsService Contacts { get; } + public DomainService Domain { get; } + public MailService Mail { get; } + public WebhookService Webhook { get; } + public InboxService Inbox { get; } - public class ReloopClient : IDisposable + public ReloopClient(string apiKey, string baseUrl = DefaultBaseUrl, HttpClient? httpClient = null) { - private readonly string _apiKey; - private readonly string _baseUrl; - private readonly HttpClient _httpClient; - private readonly JsonSerializerOptions _jsonOptions; + if (string.IsNullOrWhiteSpace(apiKey)) + { + throw new ArgumentException("Reloop SDK requires an apiKey."); + } - public ApiKeyService ApiKeys { get; } - public ContactsService Contacts { get; } - public DomainService Domain { get; } - public MailService Mail { get; } + _apiKey = apiKey.Trim(); + _baseUrl = NormalizeBaseUrl(baseUrl); - public ReloopClient(string apiKey, string baseUrl = "https://reloop.sh", HttpClient? httpClient = null) + _ownsHttpClient = httpClient == null; + _httpClient = httpClient ?? new HttpClient(); + + _jsonOptions = new JsonSerializerOptions + { + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + PropertyNameCaseInsensitive = true, + }; + _jsonOptionsIncludeNull = new JsonSerializerOptions { - if (string.IsNullOrWhiteSpace(apiKey)) - throw new ArgumentException("Reloop SDK requires an apiKey."); + PropertyNameCaseInsensitive = true, + }; - _apiKey = apiKey; - _baseUrl = baseUrl; + ApiKey = new ApiKeyService(this); + Contacts = new ContactsService(this); + Domain = new DomainService(this); + Mail = new MailService(this); + Webhook = new WebhookService(this); + Inbox = new InboxService(this); + } - _httpClient = httpClient ?? new HttpClient - { - BaseAddress = new Uri(_baseUrl) - }; - _httpClient.DefaultRequestHeaders.Add("x-api-key", _apiKey); - _httpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + public string BaseUrl => _baseUrl; - _jsonOptions = new JsonSerializerOptions - { - DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, - PropertyNameCaseInsensitive = true - }; - - ApiKeys = new ApiKeyService(this); - Contacts = new ContactsService(this); - Domain = new DomainService(this); - Mail = new MailService(this); - } + internal JsonSerializerOptions JsonOptions => _jsonOptions; - public async Task FetchAsync(HttpMethod method, string path, object? body = null) + public Task FetchAsync(HttpMethod method, string path, object? body = null) + { + return FetchAsync(method, path, body, null); + } + + public async Task FetchAsync( + HttpMethod method, + string path, + object? body, + Dictionary? query) + { + try { - try - { - var request = new HttpRequestMessage(method, path); + var requestUri = BuildRequestUri(path, query); + using var request = new HttpRequestMessage(method, requestUri); + request.Headers.TryAddWithoutValidation("x-api-key", _apiKey); + request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); - if (body != null) - { - var json = JsonSerializer.Serialize(body, _jsonOptions); - request.Content = new StringContent(json, Encoding.UTF8, "application/json"); - } + if (body != null) + { + var options = ShouldIncludeNulls(body) ? _jsonOptionsIncludeNull : _jsonOptions; + var json = JsonSerializer.Serialize(body, options); + request.Content = new StringContent(json, Encoding.UTF8, "application/json"); + } - var response = await _httpClient.SendAsync(request); + using var response = await _httpClient.SendAsync(request); - if (!response.IsSuccessStatusCode) + if (!response.IsSuccessStatusCode) + { + var errorText = await response.Content.ReadAsStringAsync(); + var errBody = new ApiErrorBody(); + try { - var errorBody = await response.Content.ReadAsStringAsync(); - throw new ReloopException($"Reloop API Error: {(int)response.StatusCode} {response.ReasonPhrase}. {errorBody}"); + if (!string.IsNullOrWhiteSpace(errorText)) + { + errBody = JsonSerializer.Deserialize(errorText, _jsonOptions) + ?? new ApiErrorBody(); + } } - - if (response.StatusCode == System.Net.HttpStatusCode.NoContent || typeof(T) == typeof(object)) + catch (JsonException) { - return default; + errBody.Message = errorText; } - var responseStream = await response.Content.ReadAsStreamAsync(); - return await JsonSerializer.DeserializeAsync(responseStream, _jsonOptions); + throw new ReloopApiException( + (int)response.StatusCode, + response.ReasonPhrase ?? ((int)response.StatusCode).ToString(), + errBody); + } + + if (response.StatusCode == System.Net.HttpStatusCode.NoContent) + { + return default; } - catch (HttpRequestException ex) + + var responseText = await response.Content.ReadAsStringAsync(); + if (string.IsNullOrWhiteSpace(responseText)) { - throw new ReloopException("Reloop Network Error", ex); + return default; } + + try + { + return JsonSerializer.Deserialize(responseText, _jsonOptions); + } + catch (JsonException ex) + { + throw new ReloopApiException("Reloop response parsing error: " + ex.Message, ex); + } + } + catch (ReloopApiException) + { + throw; + } + catch (HttpRequestException ex) + { + throw new ReloopApiException("Reloop network error: " + ex.Message, ex); + } + } + + internal static string NormalizeBaseUrl(string? baseUrl) + { + var trimmed = string.IsNullOrWhiteSpace(baseUrl) ? DefaultBaseUrl : baseUrl!.Trim(); + while (trimmed.EndsWith("/", StringComparison.Ordinal)) + { + trimmed = trimmed[..^1]; + } + + return string.IsNullOrEmpty(trimmed) ? DefaultBaseUrl : trimmed; + } + + private Uri BuildRequestUri(string path, Dictionary? query) + { + if (string.IsNullOrWhiteSpace(path)) + { + throw new ArgumentException("Request path is required.", nameof(path)); + } + + // Never attach the API key to a caller-supplied absolute URL (credential exfiltration). + // Check the raw path before query append; avoid Uri.TryCreate Absolute on "/..." which + // resolves as file:// on Unix and would incorrectly reject relative API paths. + if (path.IndexOf("://", StringComparison.Ordinal) >= 0 + || path.StartsWith("//", StringComparison.Ordinal)) + { + throw new ArgumentException("Request paths must be relative.", nameof(path)); + } + + var pathWithQuery = AppendQuery(path, query); + if (!pathWithQuery.StartsWith("/", StringComparison.Ordinal)) + { + pathWithQuery = "/" + pathWithQuery; + } + + return new Uri(_baseUrl + pathWithQuery); + } + + private static string AppendQuery(string path, Dictionary? query) + { + if (query == null || query.Count == 0) + { + return path; + } + + var parts = query + .Where(entry => entry.Value != null) + .Select(entry => Uri.EscapeDataString(entry.Key) + "=" + Uri.EscapeDataString(entry.Value!)) + .ToList(); + + if (parts.Count == 0) + { + return path; } - public void Dispose() + var separator = path.IndexOf('?') >= 0 ? "&" : "?"; + return path + separator + string.Join("&", parts); + } + + private static bool ShouldIncludeNulls(object body) + { + return body is IDictionary; + } + + public void Dispose() + { + if (_ownsHttpClient) { _httpClient.Dispose(); } diff --git a/RequestParameters.cs b/RequestParameters.cs deleted file mode 100644 index bd12284..0000000 --- a/RequestParameters.cs +++ /dev/null @@ -1,127 +0,0 @@ -using System.Collections.Generic; -using System.Text.Json; - -namespace Reloop; - -public static class RequestParameters -{ - private static readonly Dictionary RequestKeyMap = new() - { - ["first_name"] = "firstName", - ["last_name"] = "lastName", - ["group_ids"] = "groupIds", - ["group_id"] = "groupId", - ["fallback_value"] = "fallbackValue", - ["default_subscription"] = "defaultSubscription", - ["channel_id"] = "channelId", - ["property_name"] = "propertyName", - ["property_type"] = "propertyType", - ["contact_id"] = "contactId", - ["rate_limit_enabled"] = "rateLimitEnabled", - ["user_id"] = "userId", - }; - - public static Dictionary ForRequest(Dictionary parameters) - { - var normalized = new Dictionary(); - - foreach (var kvp in parameters) - { - var key = kvp.Key; - var value = kvp.Value; - if (key == "unsubscribed") - { - if (!parameters.ContainsKey("status") && value is bool unsubscribed) - { - normalized["status"] = unsubscribed ? "unsubscribed" : "subscribed"; - } - continue; - } - - var apiKey = RequestKeyMap.TryGetValue(key, out var mapped) ? mapped : ToCamelCase(key); - normalized[apiKey] = NormalizeValue(value, isRequest: true); - } - - return normalized; - } - - public static Dictionary ForQuery(Dictionary options) - { - return ForRequest(options); - } - - private static object? NormalizeValue(object? value, bool isRequest) - { - if (value is Dictionary mapValue) - { - return isRequest ? ForRequest(mapValue) : mapValue; - } - - if (value is IEnumerable> listValue) - { - var converted = new List(); - foreach (var item in listValue) - { - converted.Add(NormalizeValue(item, isRequest)); - } - return converted; - } - - if (value is object?[] arrayValue) - { - var converted = new List(); - foreach (var item in arrayValue) - { - converted.Add(NormalizeValue(item, isRequest)); - } - return converted.ToArray(); - } - - return value; - } - - private static string ToCamelCase(string key) - { - if (RequestKeyMap.TryGetValue(key, out var mapped)) - { - return mapped; - } - - if (!key.Contains('_')) - { - return key; - } - - var parts = key.Split('_'); - var result = parts[0]; - for (var index = 1; index < parts.Length; index++) - { - if (string.IsNullOrEmpty(parts[index])) - { - continue; - } - - result += char.ToUpperInvariant(parts[index][0]) + parts[index][1..]; - } - - return result; - } - - public static string BuildQuery(Dictionary values) - { - if (values.Count == 0) - { - return string.Empty; - } - - var query = new List(); - foreach (var kvp in values) - { - var key = kvp.Key; - var value = kvp.Value; - query.Add($"{Uri.EscapeDataString(key)}={Uri.EscapeDataString($"{value}")}"); - } - - return "?" + string.Join("&", query); - } -} diff --git a/Services/ApiKeyService.cs b/Services/ApiKeyService.cs index e1d729a..47b44d1 100644 --- a/Services/ApiKeyService.cs +++ b/Services/ApiKeyService.cs @@ -1,79 +1,115 @@ using System.Collections.Generic; using System.Net.Http; using System.Threading.Tasks; -using System.Web; using Reloop.Models; +using Reloop.Validation; +using static Reloop.Models.ApiKeyModels; -namespace Reloop.Services +namespace Reloop.Services; + +/** Manages organization API keys. */ +public class ApiKeyService { - public class ApiKeyService + private const string ApiKeyV1 = "/api/api-key/v1"; + + private readonly ReloopClient _client; + + internal ApiKeyService(ReloopClient client) { - private readonly ReloopClient _client; + _client = client; + } - internal ApiKeyService(ReloopClient client) - { - _client = client; - } + public Task CreateAsync(CreateApiKeyParams? parameters) + { + var name = Validators.RequireApiKeyName(parameters?.Name, "name"); + return _client.FetchAsync( + HttpMethod.Post, + ApiKeyV1 + "/", + new Dictionary { ["name"] = name }); + } - public Task CreateAsync(CreateApiKeyParams @params) + public Task ListAsync(ApiKeyListParams? parameters = null) + { + var query = new Dictionary(); + if (parameters != null) { - return _client.FetchAsync(HttpMethod.Post, "/api/api-key/v1/", @params); - } + if (parameters.Page.HasValue) + { + Validators.RequirePage(parameters.Page.Value, "page"); + query["page"] = parameters.Page.Value.ToString(); + } - public Task ListAsync(ApiKeyListParams? @params = null) - { - var query = new List(); - if (@params != null) + if (parameters.Limit.HasValue) { - if (@params.Page.HasValue) query.Add($"page={@params.Page.Value}"); - if (@params.Limit.HasValue) query.Add($"limit={@params.Limit.Value}"); - if (@params.Enabled.HasValue) query.Add($"enabled={@params.Enabled.Value.ToString().ToLower()}"); - if (!string.IsNullOrEmpty(@params.UserId)) query.Add($"userId={HttpUtility.UrlEncode(@params.UserId)}"); - if (!string.IsNullOrEmpty(@params.Q)) query.Add($"q={HttpUtility.UrlEncode(@params.Q)}"); + Validators.RequireLimit(parameters.Limit.Value, 1, 100, "limit"); + query["limit"] = parameters.Limit.Value.ToString(); } - var path = "/api/api-key/v1/"; - if (query.Count > 0) + if (parameters.Enabled.HasValue) { - path += "?" + string.Join("&", query); + query["enabled"] = parameters.Enabled.Value.ToString().ToLowerInvariant(); } - return _client.FetchAsync(HttpMethod.Get, path); - } + if (parameters.UserId != null) + { + query["userId"] = parameters.UserId; + } - public Task GetAsync(string id) - { - return _client.FetchAsync(HttpMethod.Get, $"/api/api-key/v1/{id}"); + if (parameters.Q != null) + { + query["q"] = parameters.Q; + } } - public Task UpdateAsync(string id, UpdateApiKeyParams @params) - { - return _client.FetchAsync(new HttpMethod("PATCH"), $"/api/api-key/v1/{id}", @params); - } + return _client.FetchAsync(HttpMethod.Get, ApiKeyV1 + "/", null, query); + } - public Task DeleteAsync(string id) - { - return _client.FetchAsync(HttpMethod.Delete, $"/api/api-key/v1/{id}"); - } + public Task GetAsync(string apiKeyId) + { + var id = Validators.RequireApiKeyId(apiKeyId, "apiKeyId"); + return _client.FetchAsync(HttpMethod.Get, ApiKeyV1 + "/" + id); + } - public Task RotateAsync(string id) - { - return _client.FetchAsync(HttpMethod.Post, $"/api/api-key/v1/rotate/{id}"); - } + public Task UpdateAsync(string apiKeyId, UpdateApiKeyParams? parameters) + { + var id = Validators.RequireApiKeyId(apiKeyId, "apiKeyId"); + var name = Validators.RequireApiKeyName(parameters?.Name, "name"); + return _client.FetchAsync( + new HttpMethod("PATCH"), + ApiKeyV1 + "/" + id, + new Dictionary { ["name"] = name }); + } - public Task EnableAsync(string id) - { - return _client.FetchAsync(HttpMethod.Post, $"/api/api-key/v1/enable/{id}"); - } + public Task DeleteAsync(string apiKeyId) + { + var id = Validators.RequireApiKeyId(apiKeyId, "apiKeyId"); + return _client.FetchAsync(HttpMethod.Delete, ApiKeyV1 + "/" + id); + } - public Task DisableAsync(string id) - { - return _client.FetchAsync(HttpMethod.Post, $"/api/api-key/v1/disable/{id}"); - } + public Task RotateAsync(string apiKeyId) + { + var id = Validators.RequireApiKeyId(apiKeyId, "apiKeyId"); + return _client.FetchAsync( + HttpMethod.Post, + ApiKeyV1 + "/rotate/" + id, + new Dictionary()); + } - public Task PauseAsync(string id) - { - return DisableAsync(id); - } + public Task EnableAsync(string apiKeyId) + { + var id = Validators.RequireApiKeyId(apiKeyId, "apiKeyId"); + return _client.FetchAsync( + HttpMethod.Post, + ApiKeyV1 + "/enable/" + id, + new Dictionary()); + } + + public Task DisableAsync(string apiKeyId) + { + var id = Validators.RequireApiKeyId(apiKeyId, "apiKeyId"); + return _client.FetchAsync( + HttpMethod.Post, + ApiKeyV1 + "/disable/" + id, + new Dictionary()); } } diff --git a/Services/ContactChannelsService.cs b/Services/ContactChannelsService.cs index 5ac8663..bc68996 100644 --- a/Services/ContactChannelsService.cs +++ b/Services/ContactChannelsService.cs @@ -1,12 +1,19 @@ using System.Collections.Generic; using System.Net.Http; -using System.Text.Json; using System.Threading.Tasks; +using Reloop.Exceptions; +using Reloop.Validation; +using static Reloop.Models.ContactModels; namespace Reloop.Services; +/** Manages contact channels and channel subscriptions. */ public class ContactChannelsService { + private const string ChannelsBase = "/api/contacts/v1/channels"; + private const string ChannelMembership = "/api/contacts/channel"; + private static readonly HttpMethod PatchMethod = new("PATCH"); + private readonly ReloopClient _client; internal ContactChannelsService(ReloopClient client) @@ -14,51 +21,287 @@ internal ContactChannelsService(ReloopClient client) _client = client; } - public Task CreateAsync(Dictionary parameters) + public Task CreateAsync(CreateChannelParams parameters) { - return _client.FetchAsync( - HttpMethod.Post, - "/api/contacts/v1/channels/create", - RequestParameters.ForRequest(parameters)); + var body = ValidateCreateParams(parameters); + return _client.FetchAsync(HttpMethod.Post, ChannelsBase + "/create", body); } - public Task ListAsync(Dictionary options) + public Task ListAsync(ListChannelsParams? parameters = null) { - var query = RequestParameters.BuildQuery(RequestParameters.ForQuery(options)); - return _client.FetchAsync(HttpMethod.Get, $"/api/contacts/v1/channels/list{query}"); + var query = ValidateListParams(parameters); + return _client.FetchAsync(HttpMethod.Get, ChannelsBase + "/list", null, query); } - public Task GetAsync(string channelId) + public Task GetAsync(string id) { - return _client.FetchAsync(HttpMethod.Get, $"/api/contacts/v1/channels/{channelId}"); + var channelId = RequireChannelId(id, "id"); + return _client.FetchAsync(HttpMethod.Get, ChannelsBase + "/" + channelId); } - public Task UpdateAsync(string channelId, Dictionary parameters) + public Task UpdateAsync(string id, UpdateChannelParams parameters) { - return _client.FetchAsync( - new HttpMethod("PATCH"), - $"/api/contacts/v1/channels/{channelId}", - RequestParameters.ForRequest(parameters)); + var channelId = RequireChannelId(id, "id"); + var body = ValidateUpdateParams(parameters); + return _client.FetchAsync(PatchMethod, ChannelsBase + "/" + channelId, body); } - public Task DeleteAsync(string channelId) + public Task DeleteAsync(string id) { - return _client.FetchAsync(HttpMethod.Delete, $"/api/contacts/v1/channels/{channelId}"); + var channelId = RequireChannelId(id, "id"); + return _client.FetchAsync(HttpMethod.Delete, ChannelsBase + "/" + channelId); } - public Task AddContactAsync(string channelId, Dictionary parameters) + public Task AddContactAsync(string id, AddContactToChannelParams? parameters) { - return _client.FetchAsync( + var channelId = RequireChannelId(id, "id"); + var body = ValidateAddContactParams(parameters); + return _client.FetchAsync( HttpMethod.Post, - $"/api/contacts/channel/{channelId}", - RequestParameters.ForRequest(parameters)); + ChannelMembership + "/" + channelId, + body); + } + + public Task UpdateSubscriptionAsync( + string id, + UpdateContactChannelParams? parameters) + { + var channelId = RequireChannelId(id, "id"); + var body = ValidateUpdateSubscriptionParams(parameters); + return _client.FetchAsync( + PatchMethod, + ChannelMembership + "/" + channelId, + body); + } + + private static string RequireChannelId(string? id, string field) + { + try + { + return Validators.RequireNonEmptyString(id, field); + } + catch (ReloopValidationException) + { + throw new ReloopValidationException( + "Channel " + field + " is required and must be a non-empty string.", field); + } + } + + private static string RequireChannelName(string? name, string field) + { + if (name == null) + { + throw new ReloopValidationException( + "Channel " + field + " is required and must be a string.", field); + } + + var trimmed = name.Trim(); + if (trimmed.Length < 1) + { + throw new ReloopValidationException( + "Channel " + field + " must be at least 1 character.", field); + } + + if (trimmed.Length > 255) + { + throw new ReloopValidationException( + "Channel " + field + " must be at most 255 characters.", field); + } + + return trimmed; + } + + private static void RequireOptionalDescription(string? description, string field) + { + if (description != null && description.Length > 1000) + { + throw new ReloopValidationException( + "Channel " + field + " must be at most 1000 characters.", field); + } + } + + private static void RequireOptionalSubscription(string? subscription, string field) + { + if (subscription != null + && subscription != ChannelSubscription.OptIn + && subscription != ChannelSubscription.OptOut) + { + throw new ReloopValidationException( + "Channel " + field + " must be \"opt_in\" or \"opt_out\".", field); + } + } + + private static string RequireSubscription(string? subscription, string field) + { + RequireOptionalSubscription(subscription, field); + if (subscription == null) + { + throw new ReloopValidationException( + "Channel " + field + " is required and must be \"opt_in\" or \"opt_out\".", field); + } + + return subscription; + } + + private static void RequireOptionalVisibility(string? visibility, string field) + { + if (visibility != null + && visibility != ChannelVisibility.Private + && visibility != ChannelVisibility.Public) + { + throw new ReloopValidationException( + "Channel " + field + " must be \"private\" or \"public\".", field); + } + } + + private static Dictionary ValidateMembershipParams(string? contactId, string? email) + { + var body = new Dictionary(); + if (contactId != null) + { + try + { + body["contact_id"] = Validators.RequireNonEmptyString(contactId, "contact_id"); + } + catch (ReloopValidationException) + { + throw new ReloopValidationException( + "contact_id must be a non-empty string when provided.", "contact_id"); + } + } + + if (email != null) + { + try + { + body["email"] = Validators.RequireNonEmptyString(email, "email"); + } + catch (ReloopValidationException) + { + throw new ReloopValidationException( + "email must be a non-empty string when provided.", "email"); + } + } + + if (body.Count == 0) + { + throw new ReloopValidationException("Either contact_id or email is required.", "params"); + } + + return body; + } + + private static Dictionary ValidateCreateParams(CreateChannelParams? parameters) + { + if (parameters == null) + { + throw new ReloopValidationException("create params are required and must be an object.", "params"); + } + + var body = new Dictionary + { + ["name"] = RequireChannelName(parameters.Name, "name"), + }; + + if (parameters.Description != null) + { + RequireOptionalDescription(parameters.Description, "description"); + body["description"] = parameters.Description; + } + + if (parameters.DefaultSubscription != null) + { + RequireOptionalSubscription(parameters.DefaultSubscription, "defaultSubscription"); + body["defaultSubscription"] = parameters.DefaultSubscription; + } + + if (parameters.Visibility != null) + { + RequireOptionalVisibility(parameters.Visibility, "visibility"); + body["visibility"] = parameters.Visibility; + } + + return body; + } + + private static Dictionary ValidateUpdateParams(UpdateChannelParams? parameters) + { + if (parameters == null) + { + throw new ReloopValidationException( + "update requires at least one of name, description, or visibility.", "params"); + } + + var body = new Dictionary(); + if (parameters.Name != null) + { + body["name"] = RequireChannelName(parameters.Name, "name"); + } + + if (parameters.DescriptionPresent) + { + body["description"] = parameters.DescriptionClear ? null : parameters.Description; + } + + if (parameters.Visibility != null) + { + RequireOptionalVisibility(parameters.Visibility, "visibility"); + body["visibility"] = parameters.Visibility; + } + + if (body.Count == 0) + { + throw new ReloopValidationException( + "update requires at least one of name, description, or visibility.", "params"); + } + + return body; + } + + private static Dictionary ValidateAddContactParams(AddContactToChannelParams? parameters) + { + var body = ValidateMembershipParams(parameters?.ContactId, parameters?.Email); + if (parameters?.Subscription != null) + { + RequireOptionalSubscription(parameters.Subscription, "subscription"); + body["subscription"] = parameters.Subscription; + } + + return body; } - public Task UpdateSubscriptionAsync(string channelId, Dictionary parameters) + private static Dictionary ValidateUpdateSubscriptionParams(UpdateContactChannelParams? parameters) { - return _client.FetchAsync( - new HttpMethod("PATCH"), - $"/api/contacts/channel/{channelId}", - RequestParameters.ForRequest(parameters)); + if (parameters == null) + { + throw new ReloopValidationException("Either contact_id or email is required.", "params"); + } + + var body = ValidateMembershipParams(parameters.ContactId, parameters.Email); + body["subscription"] = RequireSubscription(parameters.Subscription, "subscription"); + return body; + } + + private static Dictionary ValidateListParams(ListChannelsParams? parameters) + { + var query = new Dictionary(); + if (parameters == null) + { + return query; + } + + if (parameters.Page.HasValue) + { + Validators.RequirePage(parameters.Page.Value, "page"); + query["page"] = parameters.Page.Value.ToString(); + } + + if (parameters.Limit.HasValue) + { + Validators.RequireLimit(parameters.Limit.Value, 1, 100, "limit"); + query["limit"] = parameters.Limit.Value.ToString(); + } + + return query; } } diff --git a/Services/ContactGroupsService.cs b/Services/ContactGroupsService.cs index a8ae8d7..7b9bb58 100644 --- a/Services/ContactGroupsService.cs +++ b/Services/ContactGroupsService.cs @@ -1,12 +1,19 @@ using System.Collections.Generic; using System.Net.Http; -using System.Text.Json; using System.Threading.Tasks; +using Reloop.Exceptions; +using Reloop.Validation; +using static Reloop.Models.ContactModels; namespace Reloop.Services; +/** Manages contact groups and group membership. */ public class ContactGroupsService { + private const string GroupsBase = "/api/contacts/v1/groups"; + private const string GroupMembership = "/api/contacts/group"; + private static readonly HttpMethod PatchMethod = new("PATCH"); + private readonly ReloopClient _client; internal ContactGroupsService(ReloopClient client) @@ -14,27 +21,243 @@ internal ContactGroupsService(ReloopClient client) _client = client; } - public Task AddContactAsync(string groupId, Dictionary parameters) + public Task CreateAsync(CreateGroupParams? parameters) { - return _client.FetchAsync( + var name = RequireCreateGroupName(parameters?.Name, "name"); + return _client.FetchAsync( HttpMethod.Post, - $"/api/contacts/group/{groupId}", - RequestParameters.ForRequest(parameters)); + GroupsBase + "/create", + new Dictionary { ["name"] = name }); } - public Task RemoveContactAsync(string groupId, Dictionary parameters) + public Task ListAsync(ListGroupsParams? parameters = null) { - return _client.FetchAsync( - HttpMethod.Delete, - $"/api/contacts/group/{groupId}", - RequestParameters.ForRequest(parameters)); + var query = ValidateListParams(parameters); + return _client.FetchAsync(HttpMethod.Get, GroupsBase + "/list", null, query); + } + + public Task GetAsync(string id) + { + var groupId = RequireGroupId(id, "id"); + return _client.FetchAsync(HttpMethod.Get, GroupsBase + "/" + groupId); + } + + public Task UpdateAsync(string id, UpdateGroupParams? parameters) + { + var groupId = RequireGroupId(id, "id"); + var name = RequireUpdateGroupName(parameters?.Name, "name"); + return _client.FetchAsync( + PatchMethod, + GroupsBase + "/" + groupId, + new Dictionary { ["name"] = name }); } - public Task ListContactsAsync(string groupId, Dictionary options) + public Task DeleteAsync(string id) { - var query = RequestParameters.BuildQuery(RequestParameters.ForQuery(options)); - return _client.FetchAsync( + var groupId = RequireGroupId(id, "id"); + return _client.FetchAsync(HttpMethod.Delete, GroupsBase + "/" + groupId); + } + + public Task ListContactsAsync(string id, ListGroupContactsParams? parameters = null) + { + var groupId = RequireGroupId(id, "id"); + var query = ValidateListContactsParams(parameters); + return _client.FetchAsync( HttpMethod.Get, - $"/api/contacts/v1/groups/{groupId}/contacts{query}"); + GroupsBase + "/" + groupId + "/contacts", + null, + query); + } + + public Task AddContactAsync(string id, GroupMembershipParams? parameters) + { + var groupId = RequireGroupId(id, "id"); + var body = ValidateMembershipParams(parameters); + return _client.FetchAsync( + HttpMethod.Post, + GroupMembership + "/" + groupId, + body); + } + + public Task RemoveContactAsync(string id, GroupMembershipParams? parameters) + { + var groupId = RequireGroupId(id, "id"); + var body = ValidateMembershipParams(parameters); + return _client.FetchAsync( + HttpMethod.Delete, + GroupMembership + "/" + groupId, + body); + } + + private static string RequireGroupId(string? id, string field) + { + try + { + return Validators.RequireNonEmptyString(id, field); + } + catch (ReloopValidationException) + { + throw new ReloopValidationException( + "Group " + field + " is required and must be a non-empty string.", field); + } + } + + private static string RequireCreateGroupName(string? name, string field) + { + if (name == null) + { + throw new ReloopValidationException( + "Group " + field + " is required and must be a string.", field); + } + + var trimmed = name.Trim(); + if (trimmed.Length < 1) + { + throw new ReloopValidationException( + "Group " + field + " must be at least 1 character.", field); + } + + if (trimmed.Length > 50) + { + throw new ReloopValidationException( + "Group " + field + " must be at most 50 characters.", field); + } + + return trimmed; + } + + private static string RequireUpdateGroupName(string? name, string field) + { + if (name == null) + { + throw new ReloopValidationException( + "Group " + field + " is required and must be a string.", field); + } + + var trimmed = name.Trim(); + if (trimmed.Length < 1) + { + throw new ReloopValidationException( + "Group " + field + " must be at least 1 character.", field); + } + + if (trimmed.Length > 255) + { + throw new ReloopValidationException( + "Group " + field + " must be at most 255 characters.", field); + } + + return trimmed; + } + + private static void RequireListContactStatus(string? status, string field) + { + if (status == null + || (status != ContactStatus.Subscribed + && status != ContactStatus.Unsubscribed + && status != ContactStatus.Blocked)) + { + throw new ReloopValidationException( + "list " + field + " must be \"subscribed\", \"unsubscribed\", or \"blocked\".", field); + } + } + + private static Dictionary ValidateMembershipParams(GroupMembershipParams? parameters) + { + var body = new Dictionary(); + if (parameters?.ContactId != null) + { + try + { + body["contact_id"] = Validators.RequireNonEmptyString(parameters.ContactId, "contact_id"); + } + catch (ReloopValidationException) + { + throw new ReloopValidationException( + "contact_id must be a non-empty string when provided.", "contact_id"); + } + } + + if (parameters?.Email != null) + { + try + { + body["email"] = Validators.RequireNonEmptyString(parameters.Email, "email"); + } + catch (ReloopValidationException) + { + throw new ReloopValidationException( + "email must be a non-empty string when provided.", "email"); + } + } + + if (body.Count == 0) + { + throw new ReloopValidationException("Either contact_id or email is required.", "params"); + } + + return body; + } + + private static Dictionary ValidateListParams(ListGroupsParams? parameters) + { + var query = new Dictionary(); + if (parameters == null) + { + return query; + } + + if (parameters.Page.HasValue) + { + Validators.RequirePage(parameters.Page.Value, "page"); + query["page"] = parameters.Page.Value.ToString(); + } + + if (parameters.Limit.HasValue) + { + Validators.RequireLimit(parameters.Limit.Value, 1, 100, "limit"); + query["limit"] = parameters.Limit.Value.ToString(); + } + + if (parameters.Search != null) + { + query["search"] = parameters.Search; + } + + return query; + } + + private static Dictionary ValidateListContactsParams(ListGroupContactsParams? parameters) + { + var query = new Dictionary(); + if (parameters == null) + { + return query; + } + + if (parameters.Page.HasValue) + { + Validators.RequirePage(parameters.Page.Value, "page"); + query["page"] = parameters.Page.Value.ToString(); + } + + if (parameters.Limit.HasValue) + { + Validators.RequireLimit(parameters.Limit.Value, 1, 100, "limit"); + query["limit"] = parameters.Limit.Value.ToString(); + } + + if (parameters.Search != null) + { + query["search"] = parameters.Search; + } + + if (parameters.Status != null) + { + RequireListContactStatus(parameters.Status, "status"); + query["status"] = parameters.Status; + } + + return query; } } diff --git a/Services/ContactPropertiesService.cs b/Services/ContactPropertiesService.cs new file mode 100644 index 0000000..badaa23 --- /dev/null +++ b/Services/ContactPropertiesService.cs @@ -0,0 +1,158 @@ +using System.Collections.Generic; +using System.Net.Http; +using System.Threading.Tasks; +using Reloop.Exceptions; +using Reloop.Validation; +using static Reloop.Models.ContactModels; + +namespace Reloop.Services; + +/** Manages contact property definitions. */ +public class ContactPropertiesService +{ + private const string PropertiesBase = "/api/contacts/v1/properties"; + private static readonly HttpMethod PatchMethod = new("PATCH"); + + private readonly ReloopClient _client; + + internal ContactPropertiesService(ReloopClient client) + { + _client = client; + } + + public Task CreateAsync(CreatePropertyParams parameters) + { + var body = ValidateCreateParams(parameters); + return _client.FetchAsync(HttpMethod.Post, PropertiesBase + "/create", body); + } + + public Task ListAsync(ListPropertiesParams? parameters = null) + { + var query = ValidateListParams(parameters); + return _client.FetchAsync(HttpMethod.Get, PropertiesBase + "/list", null, query); + } + + public Task UpdateAsync(string id, UpdatePropertyParams? parameters = null) + { + var propertyId = RequirePropertyId(id, "id"); + var body = ValidateUpdateParams(parameters); + return _client.FetchAsync(PatchMethod, PropertiesBase + "/" + propertyId, body); + } + + public Task DeleteAsync(string id) + { + var propertyId = RequirePropertyId(id, "id"); + return _client.FetchAsync(HttpMethod.Delete, PropertiesBase + "/" + propertyId); + } + + private static string RequirePropertyId(string? id, string field) + { + try + { + return Validators.RequireNonEmptyString(id, field); + } + catch (ReloopValidationException) + { + throw new ReloopValidationException( + "Property " + field + " is required and must be a non-empty string.", field); + } + } + + private static string RequirePropertyName(string? name, string field) + { + if (name == null) + { + throw new ReloopValidationException( + "Property " + field + " is required and must be a string.", field); + } + + var trimmed = name.Trim(); + if (trimmed.Length < 1) + { + throw new ReloopValidationException( + "Property " + field + " must be at least 1 character.", field); + } + + if (trimmed.Length > 255) + { + throw new ReloopValidationException( + "Property " + field + " must be at most 255 characters.", field); + } + + return trimmed; + } + + private static string RequirePropertyType(string? type, string field) + { + if (type == null || (type != PropertyType.String && type != PropertyType.Number)) + { + throw new ReloopValidationException( + "Property " + field + " must be \"string\" or \"number\".", field); + } + + return type; + } + + private static Dictionary ValidateCreateParams(CreatePropertyParams? parameters) + { + if (parameters == null) + { + throw new ReloopValidationException("create params are required and must be an object.", "params"); + } + + var body = new Dictionary + { + ["name"] = RequirePropertyName(parameters.Name, "name"), + ["type"] = RequirePropertyType(parameters.Type, "type"), + }; + + if (parameters.FallbackValue != null) + { + body["fallbackValue"] = parameters.FallbackValue; + } + + return body; + } + + private static Dictionary ValidateUpdateParams(UpdatePropertyParams? parameters) + { + return new Dictionary + { + ["fallbackValue"] = parameters?.FallbackValue, + }; + } + + private static Dictionary ValidateListParams(ListPropertiesParams? parameters) + { + var query = new Dictionary(); + if (parameters == null) + { + return query; + } + + if (parameters.Page.HasValue) + { + Validators.RequirePage(parameters.Page.Value, "page"); + query["page"] = parameters.Page.Value.ToString(); + } + + if (parameters.Limit.HasValue) + { + Validators.RequireLimit(parameters.Limit.Value, 1, 100, "limit"); + query["limit"] = parameters.Limit.Value.ToString(); + } + + if (parameters.Search != null) + { + query["search"] = parameters.Search; + } + + if (parameters.Type != null) + { + RequirePropertyType(parameters.Type, "type"); + query["type"] = parameters.Type; + } + + return query; + } +} diff --git a/Services/ContactsService.cs b/Services/ContactsService.cs index 9b8b5c7..3affaad 100644 --- a/Services/ContactsService.cs +++ b/Services/ContactsService.cs @@ -1,120 +1,336 @@ -using System.Collections.Generic; using System.Net.Http; -using System.Text.Json; -using System.Threading.Tasks; +using System.Text.RegularExpressions; +using Reloop.Exceptions; +using Reloop.Validation; +using static Reloop.Models.ContactModels; namespace Reloop.Services; +/** Manages contacts and nested property/group/channel services. */ public class ContactsService { + private const string ContactsBase = "/api/contacts"; + private static readonly Regex EmailPattern = new(@"^[^\s@]+@[^\s@]+\.[^\s@]+$", RegexOptions.Compiled); + private static readonly Regex CreatePropertyKeyPattern = new(@"^[a-z0-9_]+$", RegexOptions.Compiled); + private static readonly Regex UpdatePropertyKeyPattern = new(@"^[a-z0-9_]+$", RegexOptions.Compiled); + private readonly ReloopClient _client; + public ContactPropertiesService Properties { get; } public ContactGroupsService Groups { get; } public ContactChannelsService Channels { get; } internal ContactsService(ReloopClient client) { _client = client; + Properties = new ContactPropertiesService(client); Groups = new ContactGroupsService(client); Channels = new ContactChannelsService(client); } - public Task CreateAsync(Dictionary parameters) + public Task CreateAsync(CreateContactParams parameters) { - return _client.FetchAsync( - HttpMethod.Post, - "/api/contacts/create", - RequestParameters.ForRequest(parameters)); + var body = ValidateCreateParams(parameters); + return _client.FetchAsync(HttpMethod.Post, ContactsBase + "/create", body); } - public Task GetAsync(string contactId) + public Task GetAsync(string id) { - return _client.FetchAsync(HttpMethod.Get, $"/api/contacts/retrieve/{contactId}"); + var contactId = RequireContactId(id, "id"); + return _client.FetchAsync(HttpMethod.Get, ContactsBase + "/retrieve/" + contactId); } - public Task ListAsync(Dictionary options) + public Task ListAsync(ListContactsParams? parameters = null) { - if (options.TryGetValue("group_id", out var groupId) || options.TryGetValue("groupId", out groupId)) - { - var filtered = new Dictionary(options); - filtered.Remove("group_id"); - filtered.Remove("groupId"); - return Groups.ListContactsAsync($"{groupId}", filtered); - } - - var query = RequestParameters.BuildQuery(RequestParameters.ForQuery(options)); - return _client.FetchAsync(HttpMethod.Get, $"/api/contacts/list{query}"); + var query = ValidateListParams(parameters); + return _client.FetchAsync(HttpMethod.Get, ContactsBase + "/list", null, query); } - public Task UpdateAsync(string contactId, Dictionary parameters) + public Task UpdateAsync(string id, UpdateContactParams parameters) { - return _client.FetchAsync( - new HttpMethod("PATCH"), - $"/api/contacts/{contactId}", - RequestParameters.ForRequest(parameters)); + var contactId = RequireContactId(id, "id"); + var body = ValidateUpdateParams(parameters); + return _client.FetchAsync(new HttpMethod("PATCH"), ContactsBase + "/" + contactId, body); } - public Task DeleteAsync(string contactId) + public Task DeleteAsync(string id) { - return _client.FetchAsync(HttpMethod.Delete, $"/api/contacts/{contactId}"); + var contactId = RequireContactId(id, "id"); + return _client.FetchAsync(HttpMethod.Delete, ContactsBase + "/" + contactId); } - public Task CreatePropertyAsync(Dictionary parameters) + internal static string RequireContactId(string? id, string field) { - return _client.FetchAsync( - HttpMethod.Post, - "/api/contacts/v1/properties/create", - RequestParameters.ForRequest(parameters)); + try + { + return Validators.RequireNonEmptyString(id, field); + } + catch (ReloopValidationException) + { + throw new ReloopValidationException( + "Contact " + field + " is required and must be a non-empty string.", field); + } } - public Task ListPropertiesAsync(Dictionary options) + internal static string RequireContactEmail(string? email, string field) { - var query = RequestParameters.BuildQuery(RequestParameters.ForQuery(options)); - return _client.FetchAsync(HttpMethod.Get, $"/api/contacts/v1/properties/list{query}"); + if (email == null) + { + throw new ReloopValidationException( + "Contact " + field + " is required and must be a string.", field); + } + + var trimmed = email.Trim(); + if (trimmed.Length == 0) + { + throw new ReloopValidationException( + "Contact " + field + " is required and must be a string.", field); + } + + if (!EmailPattern.IsMatch(trimmed)) + { + throw new ReloopValidationException( + "Contact " + field + " must be a valid email address.", field); + } + + return trimmed; } - public Task UpdatePropertyAsync(string propertyId, Dictionary parameters) + internal static void RequireContactStatus(string? status, string field) { - return _client.FetchAsync( - new HttpMethod("PATCH"), - $"/api/contacts/v1/properties/{propertyId}", - RequestParameters.ForRequest(parameters)); + if (status == null + || (status != ContactStatus.Subscribed + && status != ContactStatus.Unsubscribed + && status != ContactStatus.Blocked)) + { + throw new ReloopValidationException( + field + " must be \"subscribed\", \"unsubscribed\", or \"blocked\".", field); + } } - public Task DeletePropertyAsync(string propertyId) + private static Dictionary ValidateProperties( + Dictionary? properties, + Regex keyPattern, + string field) { - return _client.FetchAsync(HttpMethod.Delete, $"/api/contacts/v1/properties/{propertyId}"); + if (properties == null) + { + return new Dictionary(); + } + + var output = new Dictionary(); + foreach (var entry in properties) + { + if (!keyPattern.IsMatch(entry.Key)) + { + throw new ReloopValidationException( + field + " keys must match " + keyPattern + ".", field); + } + + if (entry.Value is not string and not double and not float and not int and not long and not decimal) + { + throw new ReloopValidationException( + field + "." + entry.Key + " must be a string or number.", field); + } + + output[entry.Key] = entry.Value; + } + + return output; } - public Task CreateGroupAsync(Dictionary parameters) + private static List? ValidateGroupIds(List? groupIds) { - return _client.FetchAsync( - HttpMethod.Post, - "/api/contacts/v1/groups/create", - RequestParameters.ForRequest(parameters)); + if (groupIds == null) + { + return null; + } + + return groupIds.Select(id => + { + try + { + return Validators.RequireNonEmptyString(id, "groupIds"); + } + catch (ReloopValidationException) + { + throw new ReloopValidationException( + "groupIds must contain non-empty strings.", "groupIds"); + } + }).ToList(); } - public Task ListGroupsAsync(Dictionary options) + private static List? ValidateChannels(List? channels) { - var query = RequestParameters.BuildQuery(RequestParameters.ForQuery(options)); - return _client.FetchAsync(HttpMethod.Get, $"/api/contacts/v1/groups/list{query}"); + if (channels == null) + { + return null; + } + + for (var i = 0; i < channels.Count; i++) + { + var entry = channels[i]; + if (entry == null) + { + throw new ReloopValidationException("channels[" + i + "] must be an object.", "channels"); + } + + try + { + Validators.RequireNonEmptyString(entry.ChannelId, "channels"); + } + catch (ReloopValidationException) + { + throw new ReloopValidationException( + "channels[" + i + "].channelId must be a non-empty string.", "channels"); + } + + if (entry.Subscription == null + || (entry.Subscription != ChannelSubscription.OptIn + && entry.Subscription != ChannelSubscription.OptOut)) + { + throw new ReloopValidationException( + "channels[" + i + "].subscription must be \"opt_in\" or \"opt_out\".", "channels"); + } + } + + return channels; } - public Task GetGroupAsync(string groupId) + private static Dictionary ValidateCreateParams(CreateContactParams? parameters) { - return _client.FetchAsync(HttpMethod.Get, $"/api/contacts/v1/groups/{groupId}"); + if (parameters == null) + { + throw new ReloopValidationException("create params are required and must be an object.", "params"); + } + + var body = new Dictionary + { + ["email"] = RequireContactEmail(parameters.Email, "email"), + }; + + if (parameters.FirstName != null) + { + body["firstName"] = parameters.FirstName; + } + + if (parameters.LastName != null) + { + body["lastName"] = parameters.LastName; + } + + if (parameters.Status != null) + { + RequireContactStatus(parameters.Status, "status"); + body["status"] = parameters.Status; + } + + if (parameters.Properties != null) + { + var properties = ValidateProperties(parameters.Properties, CreatePropertyKeyPattern, "properties"); + if (properties.Count > 0) + { + body["properties"] = properties; + } + } + + var groupIds = ValidateGroupIds(parameters.GroupIds); + if (groupIds != null) + { + body["groupIds"] = groupIds; + } + + var channels = ValidateChannels(parameters.Channels); + if (channels != null) + { + body["channels"] = channels; + } + + return body; } - public Task UpdateGroupAsync(string groupId, Dictionary parameters) + private static Dictionary ValidateUpdateParams(UpdateContactParams? parameters) { - return _client.FetchAsync( - new HttpMethod("PATCH"), - $"/api/contacts/v1/groups/{groupId}", - RequestParameters.ForRequest(parameters)); + if (parameters == null) + { + throw new ReloopValidationException( + "update requires at least one of email, firstName, lastName, status, or properties.", + "params"); + } + + var body = new Dictionary(); + + if (parameters.Email != null) + { + body["email"] = RequireContactEmail(parameters.Email, "email"); + } + + if (parameters.FirstName != null) + { + body["firstName"] = parameters.FirstName; + } + + if (parameters.LastName != null) + { + body["lastName"] = parameters.LastName; + } + + if (parameters.Status != null) + { + RequireContactStatus(parameters.Status, "status"); + body["status"] = parameters.Status; + } + + if (parameters.Properties != null) + { + var properties = ValidateProperties(parameters.Properties, UpdatePropertyKeyPattern, "properties"); + if (properties.Count > 0) + { + body["properties"] = properties; + } + } + + if (body.Count == 0) + { + throw new ReloopValidationException( + "update requires at least one of email, firstName, lastName, status, or properties.", + "params"); + } + + return body; } - public Task DeleteGroupAsync(string groupId) + private static Dictionary ValidateListParams(ListContactsParams? parameters) { - return _client.FetchAsync(HttpMethod.Delete, $"/api/contacts/v1/groups/{groupId}"); + var query = new Dictionary(); + if (parameters == null) + { + return query; + } + + if (parameters.Page.HasValue) + { + Validators.RequirePage(parameters.Page.Value, "page"); + query["page"] = parameters.Page.Value.ToString(); + } + + if (parameters.Limit.HasValue) + { + Validators.RequireLimit(parameters.Limit.Value, 1, 100, "limit"); + query["limit"] = parameters.Limit.Value.ToString(); + } + + if (parameters.Search != null) + { + query["search"] = parameters.Search; + } + + if (parameters.Status != null) + { + RequireContactStatus(parameters.Status, "status"); + query["status"] = parameters.Status; + } + + return query; } } diff --git a/Services/DomainService.cs b/Services/DomainService.cs index 1a3785a..f503a49 100644 --- a/Services/DomainService.cs +++ b/Services/DomainService.cs @@ -1,91 +1,97 @@ using System.Collections.Generic; using System.Net.Http; using System.Threading.Tasks; -using System.Web; -using Reloop.Models; +using Reloop.Validation; +using static Reloop.Models.DomainModels; -namespace Reloop.Services +namespace Reloop.Services; + +/** Manages sending/receiving domains. */ +public class DomainService { - public class DomainService + private const string DomainV1 = "/api/domain/v1"; + + private readonly ReloopClient _client; + + internal DomainService(ReloopClient client) { - private readonly ReloopClient _client; + _client = client; + } - internal DomainService(ReloopClient client) + public Task CreateAsync(CreateDomainParams? parameters) + { + var domain = Validators.RequireNonEmptyString(parameters?.Domain, "domain"); + var body = new CreateDomainParams(domain); + if (parameters != null) { - _client = client; + body.ClickTracking = parameters.ClickTracking; + body.OpenTracking = parameters.OpenTracking; + body.Tls = parameters.Tls; + body.SendingEmail = parameters.SendingEmail; + body.ReceivingEmail = parameters.ReceivingEmail; } - public Task CreateAsync(CreateDomainParams @params) - { - return _client.FetchAsync(HttpMethod.Post, "/api/domain/v1/create", @params); - } + return _client.FetchAsync(HttpMethod.Post, DomainV1 + "/create", body); + } - public Task ListAsync(ListDomainsParams? @params = null) + public Task ListAsync(ListDomainsParams? parameters = null) + { + var query = new Dictionary(); + if (parameters != null) { - var path = "/api/domain/v1/list"; - var query = BuildListQuery(@params); - if (!string.IsNullOrEmpty(query)) + if (parameters.Page.HasValue) { - path += "?" + query; + Validators.RequirePage(parameters.Page.Value, "page"); + query["page"] = parameters.Page.Value.ToString(); } - return _client.FetchAsync(HttpMethod.Get, path); - } - - public Task GetAsync(string domainId) - { - return _client.FetchAsync(HttpMethod.Get, $"/api/domain/v1/{domainId}"); - } - - public Task GetNameserversAsync(string domainId) - { - return _client.FetchAsync( - HttpMethod.Get, - $"/api/domain/v1/nameservers/{domainId}"); - } + if (parameters.Limit.HasValue) + { + Validators.RequireLimit(parameters.Limit.Value, 1, 100, "limit"); + query["limit"] = parameters.Limit.Value.ToString(); + } - public Task UpdateAsync(string domainId, UpdateDomainParams @params) - { - return _client.FetchAsync( - new HttpMethod("PATCH"), - $"/api/domain/v1/{domainId}", - @params); - } + if (parameters.Q != null) + { + query["q"] = parameters.Q; + } - public Task DeleteAsync(string domainId) - { - return _client.FetchAsync(HttpMethod.Delete, $"/api/domain/v1/{domainId}"); + if (parameters.Status != null) + { + query["status"] = parameters.Status; + } } - public Task VerifyAsync(string domainId) - { - return _client.FetchAsync( - HttpMethod.Post, - $"/api/domain/v1/verify/{domainId}"); - } + return _client.FetchAsync(HttpMethod.Get, DomainV1 + "/list", null, query); + } - public Task ForwardDnsAsync(string domainId, ForwardDnsParams @params) - { - return _client.FetchAsync( - HttpMethod.Post, - $"/api/domain/v1/verify/{domainId}/forward-dns", - @params); - } + public Task GetAsync(string domainId) + { + var id = Validators.RequirePathSegment(domainId, "domainId"); + return _client.FetchAsync(HttpMethod.Get, DomainV1 + "/" + id); + } - internal static string BuildListQuery(ListDomainsParams? @params) - { - if (@params == null) - { - return string.Empty; - } + public Task UpdateAsync(string domainId, UpdateDomainParams? parameters) + { + var id = Validators.RequirePathSegment(domainId, "domainId"); + return _client.FetchAsync( + new HttpMethod("PATCH"), + DomainV1 + "/" + id, + parameters ?? new UpdateDomainParams()); + } - var query = new List(); - if (@params.Page.HasValue) query.Add($"page={@params.Page.Value}"); - if (@params.Limit.HasValue) query.Add($"limit={@params.Limit.Value}"); - if (!string.IsNullOrEmpty(@params.Q)) query.Add($"q={HttpUtility.UrlEncode(@params.Q)}"); - if (!string.IsNullOrEmpty(@params.Status)) query.Add($"status={HttpUtility.UrlEncode(@params.Status)}"); + public Task DeleteAsync(string domainId) + { + var id = Validators.RequirePathSegment(domainId, "domainId"); + return _client.FetchAsync(HttpMethod.Delete, DomainV1 + "/" + id); + } - return string.Join("&", query); - } + public Task VerifyAsync(string domainId) + { + var id = Validators.RequirePathSegment(domainId, "domainId"); + return _client.FetchAsync( + HttpMethod.Post, + DomainV1 + "/verify/" + id, + new Dictionary()); } } diff --git a/Services/InboxMailboxesService.cs b/Services/InboxMailboxesService.cs new file mode 100644 index 0000000..747e1a2 --- /dev/null +++ b/Services/InboxMailboxesService.cs @@ -0,0 +1,73 @@ +using System.Net.Http; +using Reloop.Exceptions; +using Reloop.Validation; +using static Reloop.Models.InboxModels; + +namespace Reloop.Services; + +/** Manages inbox mailboxes. */ +public class InboxMailboxesService +{ + private const string MailboxesV1 = "/api/inbox/v1/mailboxes"; + private static readonly HttpMethod PatchMethod = new("PATCH"); + + private readonly ReloopClient _client; + + internal InboxMailboxesService(ReloopClient client) + { + _client = client; + } + + public Task ListAsync() + { + return _client.FetchAsync(HttpMethod.Get, MailboxesV1 + "/list"); + } + + public Task GetAsync(string id) + { + var mailboxId = Validators.RequireMailboxId(id, "id"); + return _client.FetchAsync(HttpMethod.Get, MailboxesV1 + "/" + mailboxId); + } + + public Task CreateAsync(CreateMailboxParams? parameters) + { + parameters ??= new CreateMailboxParams(); + var domainId = Validators.RequireNonEmptyString(parameters.DomainId, "domainId"); + var email = Validators.RequireNonEmptyString(parameters.Email, "email"); + + var body = new CreateMailboxParams + { + DomainId = domainId, + Email = email, + Password = parameters.Password, + Quota = parameters.Quota, + DisplayName = parameters.DisplayName, + }; + + return _client.FetchAsync(HttpMethod.Post, MailboxesV1 + "/create", body); + } + + public Task UpdateAsync(string id, UpdateMailboxParams? parameters) + { + var mailboxId = Validators.RequireMailboxId(id, "id"); + if (parameters == null + || (parameters.DisplayName == null && parameters.Status == null && parameters.Quota == null)) + { + throw new ReloopValidationException( + "update requires at least one of displayName, status, or quota.", "params"); + } + + if (parameters.Status != null) + { + Validators.RequireMailboxStatus(parameters.Status, "status"); + } + + return _client.FetchAsync(PatchMethod, MailboxesV1 + "/" + mailboxId, parameters); + } + + public Task DeleteAsync(string id) + { + var mailboxId = Validators.RequireMailboxId(id, "id"); + return _client.FetchAsync(HttpMethod.Delete, MailboxesV1 + "/" + mailboxId); + } +} diff --git a/Services/InboxMessagesService.cs b/Services/InboxMessagesService.cs new file mode 100644 index 0000000..ef8703b --- /dev/null +++ b/Services/InboxMessagesService.cs @@ -0,0 +1,259 @@ +using System.Collections.Generic; +using System.Net.Http; +using Reloop.Exceptions; +using Reloop.Validation; +using static Reloop.Models.InboxModels; + +namespace Reloop.Services; + +/** Manages inbox messages. */ +public class InboxMessagesService +{ + private const string MessagesV1 = "/api/inbox/v1/messages"; + private const int BatchIdsMax = 100; + private static readonly HttpMethod PatchMethod = new("PATCH"); + + private readonly ReloopClient _client; + + internal InboxMessagesService(ReloopClient client) + { + _client = client; + } + + public Task ListAsync(ListMessagesParams? parameters = null) + { + var query = BuildListMessagesQuery(parameters); + return _client.FetchAsync(HttpMethod.Get, MessagesV1, null, query); + } + + public Task ListSentAsync(ListSentMessagesParams? parameters = null) + { + var query = new Dictionary(); + if (parameters?.MailboxId != null) + { + query["mailboxId"] = parameters.MailboxId; + } + + return _client.FetchAsync(HttpMethod.Get, MessagesV1 + "/sent", null, query); + } + + public Task GetAsync(string id) + { + var messageId = Validators.RequireMessageId(id, "id"); + return _client.FetchAsync(HttpMethod.Get, MessagesV1 + "/" + messageId); + } + + public Task BatchAsync(BatchMessagesParams? parameters = null) + { + parameters ??= new BatchMessagesParams(); + var ids = Validators.RequireInboxIdArray(parameters.Ids, "ids", BatchIdsMax); + return _client.FetchAsync( + HttpMethod.Post, + MessagesV1 + "/batch", + new BatchMessagesParams { Ids = ids }); + } + + public Task GetRawAsync(string id) + { + var messageId = Validators.RequireMessageId(id, "id"); + return _client.FetchAsync(HttpMethod.Get, MessagesV1 + "/" + messageId + "/raw"); + } + + public Task GetAttachmentAsync(string id, string attachmentId) + { + var messageId = Validators.RequireMessageId(id, "id"); + var attachment = Validators.RequireInboxAttachmentId(attachmentId, "attachmentId"); + return _client.FetchAsync( + HttpMethod.Get, + MessagesV1 + "/" + messageId + "/attachments/" + attachment); + } + + public Task UpdateAsync(string id, UpdateMessageParams? parameters) + { + var messageId = Validators.RequireMessageId(id, "id"); + if (parameters == null + || (parameters.IsRead == null && parameters.IsStarred == null && parameters.IsSpam == null)) + { + throw new ReloopValidationException( + "update requires at least one of isRead, isStarred, or isSpam.", "params"); + } + + return _client.FetchAsync(PatchMethod, MessagesV1 + "/" + messageId, parameters); + } + + public Task SetReadAsync(string id, SetMessageReadParams? parameters = null) + { + var messageId = Validators.RequireMessageId(id, "id"); + parameters ??= new SetMessageReadParams(); + return _client.FetchAsync( + PatchMethod, + MessagesV1 + "/" + messageId + "/read", + new SetMessageReadParams(parameters.IsRead)); + } + + public Task SetStarAsync(string id, SetMessageStarParams? parameters = null) + { + var messageId = Validators.RequireMessageId(id, "id"); + parameters ??= new SetMessageStarParams(); + return _client.FetchAsync( + PatchMethod, + MessagesV1 + "/" + messageId + "/star", + new SetMessageStarParams(parameters.IsStarred)); + } + + public Task DeleteAsync(string id) + { + var messageId = Validators.RequireMessageId(id, "id"); + return _client.FetchAsync(HttpMethod.Delete, MessagesV1 + "/" + messageId); + } + + public Task SendAsync(SendMessageParams? parameters) + { + parameters ??= new SendMessageParams(); + var mailboxId = Validators.RequireNonEmptyString(parameters.MailboxId, "mailboxId"); + var to = Validators.RequireRecipient(parameters.To, "to"); + var subject = Validators.RequireNonEmptyString(parameters.Subject, "subject"); + Validators.RequireComposeBody(parameters.Text, parameters.Html); + if (parameters.UndoWindowSeconds.HasValue) + { + Validators.RequireFiniteNumber(parameters.UndoWindowSeconds.Value, "undoWindowSeconds"); + } + + var body = new SendMessageParams + { + MailboxId = mailboxId, + To = to, + Subject = subject, + Text = parameters.Text, + Html = parameters.Html, + ScheduledAt = parameters.ScheduledAt, + UndoWindowSeconds = parameters.UndoWindowSeconds, + Attachments = parameters.Attachments, + }; + + if (parameters.Cc != null) + { + body.Cc = Validators.RequireRecipient(parameters.Cc, "cc"); + } + + if (parameters.Bcc != null) + { + body.Bcc = Validators.RequireRecipient(parameters.Bcc, "bcc"); + } + + return _client.FetchAsync(HttpMethod.Post, MessagesV1 + "/send", body); + } + + public Task CancelPendingAsync(string id) + { + var messageId = Validators.RequireMessageId(id, "id"); + return _client.FetchAsync( + HttpMethod.Post, + MessagesV1 + "/pending/" + messageId + "/cancel"); + } + + public Task ReplyAsync(string id, ComposeMessageParams? parameters) + { + var messageId = Validators.RequireMessageId(id, "id"); + var body = BuildComposeBody(parameters); + return _client.FetchAsync( + HttpMethod.Post, + MessagesV1 + "/" + messageId + "/reply", + body); + } + + public Task ReplyAllAsync(string id, ComposeMessageParams? parameters) + { + var messageId = Validators.RequireMessageId(id, "id"); + var body = BuildComposeBody(parameters); + return _client.FetchAsync( + HttpMethod.Post, + MessagesV1 + "/" + messageId + "/reply-all", + body); + } + + public Task ForwardAsync(string id, ForwardMessageParams? parameters) + { + var messageId = Validators.RequireMessageId(id, "id"); + parameters ??= new ForwardMessageParams(); + var to = Validators.RequireRecipient(parameters.To, "to"); + var body = BuildComposeBody(parameters); + body["to"] = to; + return _client.FetchAsync( + HttpMethod.Post, + MessagesV1 + "/" + messageId + "/forward", + body); + } + + private static Dictionary BuildListMessagesQuery(ListMessagesParams? parameters) + { + var query = new Dictionary(); + if (parameters == null) + { + return query; + } + + if (parameters.MailboxId != null) + { + query["mailboxId"] = parameters.MailboxId; + } + + if (parameters.Limit.HasValue) + { + Validators.RequireInboxLimit(parameters.Limit.Value, "limit"); + query["limit"] = parameters.Limit.Value.ToString(); + } + + if (parameters.Offset.HasValue) + { + Validators.RequireInboxOffset(parameters.Offset.Value, "offset"); + query["offset"] = parameters.Offset.Value.ToString(); + } + + if (parameters.Q != null) + { + query["q"] = parameters.Q; + } + + if (parameters.IsSpam.HasValue) + { + query["isSpam"] = parameters.IsSpam.Value.ToString().ToLowerInvariant(); + } + + return query; + } + + private static Dictionary BuildComposeBody(ComposeMessageParams? parameters) + { + parameters ??= new ComposeMessageParams(); + Validators.RequireComposeBody(parameters.Text, parameters.Html); + + var body = new Dictionary(); + if (parameters.Text != null) + { + body["text"] = parameters.Text; + } + + if (parameters.Html != null) + { + body["html"] = parameters.Html; + } + + if (parameters.Cc != null) + { + body["cc"] = Validators.RequireRecipient(parameters.Cc, "cc"); + } + + if (parameters.Bcc != null) + { + body["bcc"] = Validators.RequireRecipient(parameters.Bcc, "bcc"); + } + + if (parameters.Attachments != null) + { + body["attachments"] = parameters.Attachments; + } + + return body; + } +} diff --git a/Services/InboxService.cs b/Services/InboxService.cs new file mode 100644 index 0000000..4858229 --- /dev/null +++ b/Services/InboxService.cs @@ -0,0 +1,16 @@ +namespace Reloop.Services; + +/** Inbox mailboxes, messages, and threads. */ +public class InboxService +{ + public InboxMailboxesService Mailboxes { get; } + public InboxMessagesService Messages { get; } + public InboxThreadsService Threads { get; } + + internal InboxService(ReloopClient client) + { + Mailboxes = new InboxMailboxesService(client); + Messages = new InboxMessagesService(client); + Threads = new InboxThreadsService(client); + } +} diff --git a/Services/InboxThreadsService.cs b/Services/InboxThreadsService.cs new file mode 100644 index 0000000..b18d93e --- /dev/null +++ b/Services/InboxThreadsService.cs @@ -0,0 +1,171 @@ +using System.Collections.Generic; +using System.Net.Http; +using Reloop.Exceptions; +using Reloop.Validation; +using static Reloop.Models.InboxModels; +using InboxThread = Reloop.Models.InboxModels.Thread; + +namespace Reloop.Services; + +/** Manages inbox threads. */ +public class InboxThreadsService +{ + private const string ThreadsV1 = "/api/inbox/v1/threads"; + private const int BatchIdsMax = 100; + private static readonly HttpMethod PatchMethod = new("PATCH"); + + private readonly ReloopClient _client; + + internal InboxThreadsService(ReloopClient client) + { + _client = client; + } + + public Task ListAsync(ListThreadsParams? parameters = null) + { + var query = BuildListThreadsQuery(parameters); + return _client.FetchAsync(HttpMethod.Get, ThreadsV1, null, query); + } + + public Task BatchAsync(BatchThreadsParams? parameters = null) + { + parameters ??= new BatchThreadsParams(); + var ids = Validators.RequireInboxIdArray(parameters.Ids, "ids", BatchIdsMax); + Validators.RequireThreadBatchAction(parameters.Action); + return _client.FetchAsync( + HttpMethod.Post, + ThreadsV1 + "/batch", + new BatchThreadsParams { Ids = ids, Action = parameters.Action }); + } + + public Task GetAsync(string id) + { + var threadId = Validators.RequireThreadId(id, "id"); + return _client.FetchAsync(HttpMethod.Get, ThreadsV1 + "/" + threadId); + } + + public Task GetAttachmentAsync(string id, string attachmentId) + { + var threadId = Validators.RequireThreadId(id, "id"); + var attachment = Validators.RequireInboxAttachmentId(attachmentId, "attachmentId"); + return _client.FetchAsync( + HttpMethod.Get, + ThreadsV1 + "/" + threadId + "/attachments/" + attachment); + } + + public Task UpdateAsync(string id, UpdateThreadParams? parameters) + { + var threadId = Validators.RequireThreadId(id, "id"); + if (parameters == null + || (parameters.IsRead == null + && parameters.IsStarred == null + && parameters.IsImportant == null + && parameters.IsPinned == null + && parameters.Status == null)) + { + throw new ReloopValidationException( + "update requires at least one of isRead, isStarred, isImportant, isPinned, or status.", + "params"); + } + + if (parameters.Status != null) + { + Validators.RequireThreadStatus(parameters.Status, "status"); + } + + return _client.FetchAsync(PatchMethod, ThreadsV1 + "/" + threadId, parameters); + } + + public Task SetReadAsync(string id, SetThreadReadParams? parameters = null) + { + var threadId = Validators.RequireThreadId(id, "id"); + parameters ??= new SetThreadReadParams(); + return _client.FetchAsync( + PatchMethod, + ThreadsV1 + "/" + threadId + "/read", + new SetThreadReadParams(parameters.IsRead)); + } + + public Task SetStarAsync(string id, SetThreadStarParams? parameters = null) + { + var threadId = Validators.RequireThreadId(id, "id"); + parameters ??= new SetThreadStarParams(); + return _client.FetchAsync( + PatchMethod, + ThreadsV1 + "/" + threadId + "/star", + new SetThreadStarParams(parameters.IsStarred)); + } + + public Task ArchiveAsync(string id) + { + var threadId = Validators.RequireThreadId(id, "id"); + return _client.FetchAsync(HttpMethod.Post, ThreadsV1 + "/" + threadId + "/archive"); + } + + public Task TrashAsync(string id) + { + var threadId = Validators.RequireThreadId(id, "id"); + return _client.FetchAsync(HttpMethod.Post, ThreadsV1 + "/" + threadId + "/trash"); + } + + public Task RestoreAsync(string id) + { + var threadId = Validators.RequireThreadId(id, "id"); + return _client.FetchAsync(HttpMethod.Post, ThreadsV1 + "/" + threadId + "/restore"); + } + + public Task DeleteAsync(string id) + { + var threadId = Validators.RequireThreadId(id, "id"); + return _client.FetchAsync(HttpMethod.Delete, ThreadsV1 + "/" + threadId); + } + + private static Dictionary BuildListThreadsQuery(ListThreadsParams? parameters) + { + var query = new Dictionary(); + if (parameters == null) + { + return query; + } + + if (parameters.MailboxId != null) + { + query["mailboxId"] = parameters.MailboxId; + } + + if (parameters.Limit.HasValue) + { + Validators.RequireInboxLimit(parameters.Limit.Value, "limit"); + query["limit"] = parameters.Limit.Value.ToString(); + } + + if (parameters.Offset.HasValue) + { + Validators.RequireInboxOffset(parameters.Offset.Value, "offset"); + query["offset"] = parameters.Offset.Value.ToString(); + } + + if (parameters.Folder != null) + { + query["folder"] = parameters.Folder; + } + + if (parameters.Q != null) + { + query["q"] = parameters.Q; + } + + if (parameters.IsPinned.HasValue) + { + query["isPinned"] = parameters.IsPinned.Value.ToString().ToLowerInvariant(); + } + + if (parameters.Filter != null) + { + Validators.RequireThreadFilter(parameters.Filter, "filter"); + query["filter"] = parameters.Filter; + } + + return query; + } +} diff --git a/Services/MailService.cs b/Services/MailService.cs index 4a2794c..dbc77ec 100644 --- a/Services/MailService.cs +++ b/Services/MailService.cs @@ -1,22 +1,47 @@ -using System.Collections.Generic; using System.Net.Http; -using System.Threading.Tasks; using Reloop.Models; +using Reloop.Validation; +using static Reloop.Models.MailModels; -namespace Reloop.Services +namespace Reloop.Services; + +/** Sends transactional email. */ +public class MailService { - public class MailService + private readonly ReloopClient _client; + + internal MailService(ReloopClient client) { - private readonly ReloopClient _client; + _client = client; + } - internal MailService(ReloopClient client) - { - _client = client; - } + public Task SendAsync(SendMailParams? parameters) + { + parameters ??= new SendMailParams(); + + var from = Validators.RequireMailString(parameters.From, "from"); + var to = Validators.RequireRecipient(parameters.To, "to"); + var subject = Validators.RequireMailString(parameters.Subject, "subject"); - public Task SendAsync(Dictionary parameters) + var body = new SendMailParams { - return _client.FetchAsync(HttpMethod.Post, "/api/mail/v1/send", parameters); - } + From = from, + To = to, + Subject = subject, + Cc = parameters.Cc, + Bcc = parameters.Bcc, + Text = parameters.Text, + Html = parameters.Html, + ReplyTo = parameters.ReplyTo, + ScheduledAt = parameters.ScheduledAt, + Headers = parameters.Headers, + ChannelId = parameters.ChannelId, + Attachments = parameters.Attachments, + Tags = parameters.Tags, + Template = parameters.Template, + ThreadId = parameters.ThreadId, + }; + + return _client.FetchAsync(HttpMethod.Post, "/api/mail/v1/send", body); } } diff --git a/Services/WebhookService.cs b/Services/WebhookService.cs new file mode 100644 index 0000000..058b1b1 --- /dev/null +++ b/Services/WebhookService.cs @@ -0,0 +1,311 @@ +using System.Collections.Generic; +using System.Net.Http; +using System.Threading.Tasks; +using Reloop.Exceptions; +using Reloop.Validation; +using static Reloop.Models.WebhookModels; + +namespace Reloop.Services; + +/** Manages webhooks and verifies signed payloads. */ +public class WebhookService +{ + private const string WebhookV1 = "/api/webhook/v1"; + private static readonly HttpMethod PatchMethod = new("PATCH"); + + private readonly ReloopClient _client; + + internal WebhookService(ReloopClient client) + { + _client = client; + } + + public static WebhookEvent ConstructEvent(byte[] payload, string? signature, string secret, int? tolerance = null) + { + return WebhookVerify.ConstructEvent(payload, signature, secret, tolerance); + } + + public WebhookEvent Verify(VerifyWebhookParams parameters) + { + return WebhookVerify.Verify(parameters); + } + + public Task CreateAsync(CreateWebhookParams parameters) + { + if (parameters == null) + { + throw new ReloopValidationException("create params are required and must be an object.", "params"); + } + + Validators.RequireNonEmptyString(parameters.Url, "url"); + if (parameters.Events == null || parameters.Events.Count == 0) + { + throw new ReloopValidationException( + "events is required and must be a non-empty array.", "events"); + } + + for (var i = 0; i < parameters.Events.Count; i++) + { + Validators.RequireNonEmptyString(parameters.Events[i], "events[" + i + "]"); + } + + return _client.FetchAsync(HttpMethod.Post, WebhookV1 + "/", parameters); + } + + public Task ListAsync(ListWebhooksParams? parameters = null) + { + var query = BuildListWebhooksQuery(parameters); + return _client.FetchAsync(HttpMethod.Get, WebhookV1, null, query); + } + + public Task GetAsync(string webhookId) + { + var id = RequireWebhookId(webhookId); + return _client.FetchAsync(HttpMethod.Get, WebhookV1 + "/" + id); + } + + public Task UpdateAsync(string webhookId, UpdateWebhookParams parameters) + { + var id = RequireWebhookId(webhookId); + var body = ValidateUpdateParams(parameters); + return _client.FetchAsync(PatchMethod, WebhookV1 + "/" + id, body); + } + + public Task DeleteAsync(string webhookId) + { + var id = RequireWebhookId(webhookId); + return _client.FetchAsync(HttpMethod.Delete, WebhookV1 + "/" + id); + } + + public Task PauseAsync(string webhookId) + { + return UpdateAsync(webhookId, new UpdateWebhookParams { Status = WebhookStatus.Paused }); + } + + public Task EnableAsync(string webhookId) + { + return UpdateAsync(webhookId, new UpdateWebhookParams { Status = WebhookStatus.Active }); + } + + public Task DisableAsync(string webhookId) + { + return UpdateAsync(webhookId, new UpdateWebhookParams { Status = WebhookStatus.Disabled }); + } + + public Task TriggerAsync(TriggerWebhookParams parameters) + { + var body = ValidateTriggerParams(parameters); + return _client.FetchAsync(HttpMethod.Post, WebhookV1 + "/trigger", body); + } + + public Task ListDeliveriesAsync( + string webhookId, + ListWebhookDeliveriesParams? parameters = null) + { + var id = RequireWebhookId(webhookId); + var query = BuildListDeliveriesQuery(parameters); + return _client.FetchAsync( + HttpMethod.Get, + WebhookV1 + "/" + id + "/deliveries", + null, + query); + } + + public Task RetryDeliveryAsync(string deliveryId) + { + var id = RequireDeliveryId(deliveryId); + return _client.FetchAsync( + HttpMethod.Post, + "/api/webhook/deliveries/" + id + "/retry"); + } + + private static string RequireWebhookId(string? id) + { + try + { + return Validators.RequireNonEmptyString(id, "webhookId"); + } + catch (ReloopValidationException) + { + throw new ReloopValidationException( + "Webhook webhookId is required and must be a non-empty string.", "webhookId"); + } + } + + private static string RequireDeliveryId(string? id) + { + try + { + return Validators.RequireNonEmptyString(id, "deliveryId"); + } + catch (ReloopValidationException) + { + throw new ReloopValidationException( + "Webhook delivery deliveryId is required and must be a non-empty string.", "deliveryId"); + } + } + + private static void RequireWebhookStatus(string? status, string field) + { + if (status == null + || (status != WebhookStatus.Active + && status != WebhookStatus.Paused + && status != WebhookStatus.Disabled + && status != WebhookStatus.Failed)) + { + throw new ReloopValidationException("update status must be a valid webhook status.", field); + } + } + + private static void RequireDeliveryStatus(string? status, string field) + { + if (status == null + || (status != WebhookDeliveryStatus.Pending + && status != WebhookDeliveryStatus.Success + && status != WebhookDeliveryStatus.Failed + && status != WebhookDeliveryStatus.Retrying)) + { + throw new ReloopValidationException("listDeliveries status must be a valid delivery status.", field); + } + } + + private static UpdateWebhookParams ValidateUpdateParams(UpdateWebhookParams? parameters) + { + if (parameters == null) + { + throw new ReloopValidationException("update requires at least one field to change.", "params"); + } + + if (parameters.Description == null + && parameters.Name == null + && parameters.Url == null + && parameters.Secret == null + && parameters.Status == null + && parameters.CustomHeaders == null + && parameters.RateLimitEnabled == null + && parameters.MaxRequestsPerMinute == null + && parameters.MaxRetries == null + && parameters.RetryBackoffMultiplier == null + && parameters.FilteringOptions == null) + { + throw new ReloopValidationException("update requires at least one field to change.", "params"); + } + + if (parameters.Status != null) + { + RequireWebhookStatus(parameters.Status, "status"); + } + + if (parameters.MaxRequestsPerMinute.HasValue) + { + Validators.RequireFiniteNumber(parameters.MaxRequestsPerMinute.Value, "maxRequestsPerMinute"); + } + + if (parameters.MaxRetries.HasValue) + { + Validators.RequireFiniteNumber(parameters.MaxRetries.Value, "maxRetries"); + } + + if (parameters.RetryBackoffMultiplier.HasValue) + { + Validators.RequireFiniteNumber(parameters.RetryBackoffMultiplier.Value, "retryBackoffMultiplier"); + } + + return parameters; + } + + private static TriggerWebhookParams ValidateTriggerParams(TriggerWebhookParams? parameters) + { + if (parameters == null) + { + throw new ReloopValidationException("trigger params are required and must be an object.", "params"); + } + + var eventName = Validators.RequireNonEmptyString(parameters.Event, "event"); + if (parameters.Payload == null) + { + throw new ReloopValidationException("trigger payload is required and must be an object.", "payload"); + } + + var body = new TriggerWebhookParams(eventName, parameters.Payload); + if (parameters.OrganizationId != null) + { + body.OrganizationId = Validators.RequireNonEmptyString(parameters.OrganizationId, "organizationId"); + } + + if (parameters.UserId != null) + { + body.UserId = Validators.RequireNonEmptyString(parameters.UserId, "userId"); + } + + return body; + } + + private static Dictionary BuildListWebhooksQuery(ListWebhooksParams? parameters) + { + var query = new Dictionary(); + if (parameters == null) + { + return query; + } + + if (parameters.Page.HasValue) + { + Validators.RequirePage(parameters.Page.Value, "page"); + query["page"] = parameters.Page.Value.ToString(); + } + + if (parameters.Limit.HasValue) + { + Validators.RequireLimit(parameters.Limit.Value, 1, 100, "limit"); + query["limit"] = parameters.Limit.Value.ToString(); + } + + if (parameters.Status != null) + { + RequireWebhookStatus(parameters.Status, "status"); + query["status"] = parameters.Status; + } + + if (parameters.OrganizationId != null) + { + query["organizationId"] = Validators.RequireNonEmptyString(parameters.OrganizationId, "organizationId"); + } + + if (parameters.UserId != null) + { + query["userId"] = Validators.RequireNonEmptyString(parameters.UserId, "userId"); + } + + return query; + } + + private static Dictionary BuildListDeliveriesQuery(ListWebhookDeliveriesParams? parameters) + { + var query = new Dictionary(); + if (parameters == null) + { + return query; + } + + if (parameters.Page.HasValue) + { + Validators.RequirePage(parameters.Page.Value, "page"); + query["page"] = parameters.Page.Value.ToString(); + } + + if (parameters.Limit.HasValue) + { + Validators.RequireLimit(parameters.Limit.Value, 1, 100, "limit"); + query["limit"] = parameters.Limit.Value.ToString(); + } + + if (parameters.Status != null) + { + RequireDeliveryStatus(parameters.Status, "status"); + query["status"] = parameters.Status; + } + + return query; + } +} diff --git a/Services/WebhookVerify.cs b/Services/WebhookVerify.cs new file mode 100644 index 0000000..bf1b600 --- /dev/null +++ b/Services/WebhookVerify.cs @@ -0,0 +1,274 @@ +using System.Globalization; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using Reloop.Exceptions; +using static Reloop.Models.WebhookModels; + +namespace Reloop.Services; + +/** HMAC-SHA256 webhook signature verification. */ +public static class WebhookVerify +{ + public const string WebhookSignatureHeader = "x-webhook-signature"; + public const int DefaultToleranceSeconds = 300; + + public static WebhookEvent Verify(VerifyWebhookParams parameters) + { + if (parameters == null || string.IsNullOrEmpty(parameters.Secret)) + { + throw new WebhookSignatureException("Webhook secret is required"); + } + + if (parameters.Payload == null) + { + throw new WebhookSignatureException("Webhook payload is required"); + } + + var tolerance = parameters.Tolerance ?? DefaultToleranceSeconds; + if (tolerance < 0) + { + throw new WebhookSignatureException("Webhook signature tolerance must be non-negative"); + } + + var signatureHeader = GetHeader(parameters.Headers, WebhookSignatureHeader); + if (string.IsNullOrEmpty(signatureHeader)) + { + throw new WebhookSignatureException("Missing " + WebhookSignatureHeader + " header"); + } + + var parsed = ParseSignatureHeader(signatureHeader); + VerifyTimestamp(parsed.Timestamp, tolerance); + + var expected = ComputeExpectedSignature(parameters.Secret, parsed.Timestamp, parameters.Payload); + var valid = parsed.Signatures.Any(sig => VerifySignature(expected, sig)); + if (!valid) + { + throw new WebhookSignatureException("Webhook signature verification failed"); + } + + return ParseWebhookEvent(parameters.Payload); + } + + public static WebhookEvent ConstructEvent(byte[] payload, string? signature, string secret, int? tolerance = null) + { + var parameters = new VerifyWebhookParams + { + Payload = payload, + Secret = secret, + Tolerance = tolerance, + Headers = string.IsNullOrEmpty(signature) + ? new Dictionary() + : new Dictionary { [WebhookSignatureHeader] = signature }, + }; + + return Verify(parameters); + } + + internal static string ComputeExpectedSignature(string secret, string timestamp, byte[] payload) + { + using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret)); + var prefix = Encoding.UTF8.GetBytes(timestamp + "."); + var combined = new byte[prefix.Length + payload.Length]; + Buffer.BlockCopy(prefix, 0, combined, 0, prefix.Length); + Buffer.BlockCopy(payload, 0, combined, prefix.Length, payload.Length); + return BytesToHex(hmac.ComputeHash(combined)); + } + + private static string? GetHeader(Dictionary? headers, string name) + { + if (headers == null) + { + return null; + } + + foreach (var entry in headers) + { + if (entry.Key != null + && entry.Key.Equals(name, StringComparison.OrdinalIgnoreCase)) + { + return entry.Value; + } + } + + return null; + } + + private sealed class ParsedSignature + { + public ParsedSignature(string timestamp, string[] signatures) + { + Timestamp = timestamp; + Signatures = signatures; + } + + public string Timestamp { get; } + public string[] Signatures { get; } + } + + private static ParsedSignature ParseSignatureHeader(string header) + { + string? timestamp = null; + var signatures = new List(); + + foreach (var element in header.Split(',')) + { + var trimmed = element.Trim(); + var eqIndex = trimmed.IndexOf('='); + if (eqIndex == -1) + { + continue; + } + + var prefix = trimmed[..eqIndex]; + var value = trimmed[(eqIndex + 1)..]; + if (prefix == "t") + { + timestamp = value; + } + else if (prefix == "v1") + { + signatures.Add(value); + } + } + + if (string.IsNullOrEmpty(timestamp) || signatures.Count == 0) + { + throw new WebhookSignatureException( + "Invalid X-Webhook-Signature header: expected t= and v1= values"); + } + + return new ParsedSignature(timestamp, signatures.ToArray()); + } + + private static bool VerifySignature(string expected, string received) + { + try + { + var expectedBuf = HexToBytes(expected); + var receivedBuf = HexToBytes(received); + if (expectedBuf.Length != receivedBuf.Length) + { + return false; + } + + var diff = 0; + for (var i = 0; i < expectedBuf.Length; i++) + { + diff |= expectedBuf[i] ^ receivedBuf[i]; + } + + return diff == 0; + } + catch (FormatException) + { + return false; + } + } + + private static void VerifyTimestamp(string timestamp, int tolerance) + { + if (!double.TryParse(timestamp, NumberStyles.Float, CultureInfo.InvariantCulture, out var ts) + || double.IsNaN(ts) + || double.IsInfinity(ts)) + { + throw new WebhookSignatureException("Invalid timestamp in X-Webhook-Signature header"); + } + + var age = Math.Abs(DateTimeOffset.UtcNow.ToUnixTimeSeconds() - (long)Math.Floor(ts)); + if (age > tolerance) + { + throw new WebhookSignatureException( + "Timestamp outside tolerance: allowed drift is " + tolerance + " seconds"); + } + } + + private static WebhookEvent ParseWebhookEvent(byte[] raw) + { + JsonDocument document; + try + { + document = JsonDocument.Parse(raw); + } + catch (JsonException) + { + throw new WebhookSignatureException("Webhook payload is not valid JSON"); + } + + using (document) + { + var root = document.RootElement; + if (root.ValueKind != JsonValueKind.Object) + { + throw new WebhookSignatureException("Webhook payload must be a JSON object"); + } + + if (!root.TryGetProperty("id", out var idNode) + || idNode.ValueKind != JsonValueKind.String + || string.IsNullOrEmpty(idNode.GetString())) + { + throw new WebhookSignatureException("Webhook payload missing required field: id"); + } + + if (!root.TryGetProperty("event", out var eventNode) + || eventNode.ValueKind != JsonValueKind.String + || string.IsNullOrEmpty(eventNode.GetString())) + { + throw new WebhookSignatureException("Webhook payload missing required field: event"); + } + + if (!root.TryGetProperty("payload", out var payloadNode) + || payloadNode.ValueKind != JsonValueKind.Object) + { + throw new WebhookSignatureException("Webhook payload missing required field: payload"); + } + + if (!root.TryGetProperty("timestamp", out var timestampNode) + || timestampNode.ValueKind != JsonValueKind.Number + || !timestampNode.TryGetDouble(out var ts) + || double.IsNaN(ts) + || double.IsInfinity(ts)) + { + throw new WebhookSignatureException("Webhook payload missing required field: timestamp"); + } + + var payload = JsonSerializer.Deserialize>(payloadNode.GetRawText()) + ?? new Dictionary(); + + return new WebhookEvent + { + Id = idNode.GetString(), + Event = eventNode.GetString(), + Timestamp = ts, + Payload = payload, + }; + } + } + + private static string BytesToHex(byte[] bytes) + { + var builder = new StringBuilder(bytes.Length * 2); + foreach (var b in bytes) + { + builder.Append(b.ToString("x2", CultureInfo.InvariantCulture)); + } + + return builder.ToString(); + } + + private static byte[] HexToBytes(string hex) + { + if (hex.Length % 2 != 0) + { + throw new FormatException("Invalid hex string."); + } + + var bytes = new byte[hex.Length / 2]; + for (var i = 0; i < bytes.Length; i++) + { + bytes[i] = byte.Parse(hex.Substring(i * 2, 2), NumberStyles.HexNumber, CultureInfo.InvariantCulture); + } + + return bytes; + } +} diff --git a/Validation/Validators.cs b/Validation/Validators.cs new file mode 100644 index 0000000..c3a3948 --- /dev/null +++ b/Validation/Validators.cs @@ -0,0 +1,267 @@ +using Reloop.Exceptions; + +namespace Reloop.Validation; + +/** Shared client-side field validators (no HTTP). */ +public static class Validators +{ + public static string RequireNonEmptyString(string? value, string field) + { + if (string.IsNullOrWhiteSpace(value)) + { + throw new ReloopValidationException( + field + " is required and must be a non-empty string.", field); + } + + return value!.Trim(); + } + + public static string RequireMailString(string? value, string field) + { + return RequireNonEmptyString(value, field); + } + + public static object RequireRecipient(object? value, string field) + { + if (value is string s) + { + return RequireMailString(s, field); + } + + if (value is IEnumerable collection) + { + var list = collection.ToList(); + if (list.Count == 0) + { + throw new ReloopValidationException( + field + " must contain at least one address.", field); + } + + return list.Select(entry => RequireMailString(entry, field)).ToList(); + } + + if (value is string[] arr) + { + return RequireRecipient(arr.ToList(), field); + } + + throw new ReloopValidationException( + field + " is required and must be a string or string array.", field); + } + + public static string RequireApiKeyName(string? name, string field) + { + var trimmed = RequireNonEmptyString(name, field); + if (trimmed.Length > 255) + { + throw new ReloopValidationException( + "API key " + field + " must be at most 255 characters.", field); + } + + return trimmed; + } + + public static string RequireApiKeyId(string? id, string field) + { + return RequireNonEmptyString(id, field); + } + + public static void RequirePage(int page, string field) + { + if (page < 1) + { + throw new ReloopValidationException( + "list " + field + " must be an integer >= 1.", field); + } + } + + public static void RequireLimit(int limit, int min, int max, string field) + { + if (limit < min || limit > max) + { + throw new ReloopValidationException( + "list " + field + " must be an integer between " + min + " and " + max + ".", + field); + } + } + + public static string RequireMailboxId(string? id, string field) + { + try + { + return RequireNonEmptyString(id, field); + } + catch (ReloopValidationException) + { + throw new ReloopValidationException( + "Mailbox " + field + " is required and must be a non-empty string.", field); + } + } + + public static string RequireMessageId(string? id, string field) + { + try + { + return RequireNonEmptyString(id, field); + } + catch (ReloopValidationException) + { + throw new ReloopValidationException( + "Message " + field + " is required and must be a non-empty string.", field); + } + } + + public static string RequireThreadId(string? id, string field) + { + try + { + return RequireNonEmptyString(id, field); + } + catch (ReloopValidationException) + { + throw new ReloopValidationException( + "Thread " + field + " is required and must be a non-empty string.", field); + } + } + + public static string RequireInboxAttachmentId(string? id, string field) + { + return RequireNonEmptyString(id, field); + } + + public static void RequireInboxLimit(int limit, string field) + { + RequireLimit(limit, 1, 200, field); + } + + public static void RequireInboxOffset(int offset, string field) + { + if (offset < 0) + { + throw new ReloopValidationException( + "list " + field + " must be an integer >= 0.", field); + } + } + + public static List RequireInboxIdArray(IList? ids, string field, int max) + { + if (ids == null || ids.Count == 0) + { + throw new ReloopValidationException( + field + " is required and must be a non-empty array.", field); + } + + if (ids.Count > max) + { + throw new ReloopValidationException( + field + " must contain at most " + max + " items.", field); + } + + var output = new List(ids.Count); + for (var i = 0; i < ids.Count; i++) + { + try + { + output.Add(RequireNonEmptyString(ids[i], field)); + } + catch (ReloopValidationException) + { + throw new ReloopValidationException( + field + "[" + i + "] must be a non-empty string.", field); + } + } + + return output; + } + + public static void RequireMailboxStatus(string? status, string field) + { + if (status != "active" && status != "disabled") + { + throw new ReloopValidationException( + field + " must be \"active\" or \"disabled\".", field); + } + } + + public static void RequireThreadStatus(string? status, string field) + { + if (status != "active" && status != "archived" && status != "closed" && status != "trash") + { + throw new ReloopValidationException( + field + " must be \"active\", \"archived\", \"closed\", or \"trash\".", field); + } + } + + public static void RequireThreadFilter(string? filter, string field) + { + if (filter != "primary" && filter != "alerts" && filter != "person" && filter != "tag") + { + throw new ReloopValidationException( + field + " must be \"primary\", \"alerts\", \"person\", or \"tag\".", field); + } + } + + public static void RequireThreadBatchAction(string? action) + { + if (action == null) + { + throw new ReloopValidationException( + "batch action must be a valid thread batch action.", "action"); + } + + switch (action) + { + case "archive": + case "trash": + case "restore": + case "star": + case "unstar": + case "read": + case "unread": + case "important": + case "unimportant": + case "spam": + case "unspam": + case "pin": + case "unpin": + return; + default: + throw new ReloopValidationException( + "batch action must be a valid thread batch action.", "action"); + } + } + + public static void RequireComposeBody(string? text, string? html) + { + if (text == null && html == null) + { + throw new ReloopValidationException( + "at least one of text or html is required.", "params"); + } + } + + public static void RequireFiniteNumber(double value, string field) + { + if (double.IsNaN(value) || double.IsInfinity(value)) + { + throw new ReloopValidationException( + field + " must be a number when provided.", field); + } + } + + /// + /// Validates and percent-encodes a single URL path segment (rejects ".", "..", and path/query delimiters). + /// + public static string RequirePathSegment(string? value, string field) + { + var id = RequireNonEmptyString(value, field); + if (id == "." || id == ".." + || id.IndexOfAny(new[] { '/', '\\', '?', '#', '%' }) >= 0) + { + throw new ReloopValidationException( + field + " contains invalid path characters.", field); + } + + return Uri.EscapeDataString(id); + } +} diff --git a/Version.cs b/Version.cs new file mode 100644 index 0000000..1eea514 --- /dev/null +++ b/Version.cs @@ -0,0 +1,7 @@ +namespace Reloop; + +/** SDK release version (aligned with Node/Python/Go 2.0.0). */ +public static class Version +{ + public const string VERSION = "2.0.0"; +} diff --git a/tests/Reloop.Tests/ApiKeyServiceRouteTests.cs b/tests/Reloop.Tests/ApiKeyServiceRouteTests.cs index 3e21378..f38f399 100644 --- a/tests/Reloop.Tests/ApiKeyServiceRouteTests.cs +++ b/tests/Reloop.Tests/ApiKeyServiceRouteTests.cs @@ -1,6 +1,7 @@ using System.Net.Http; using Reloop.Models; using Xunit; +using static Reloop.Models.ApiKeyModels; namespace Reloop.Tests; @@ -11,7 +12,7 @@ private static (ReloopClient Client, MockHttpMessageHandler Handler) CreateClien var handler = new MockHttpMessageHandler(); var httpClient = new HttpClient(handler) { - BaseAddress = new Uri("https://reloop.sh") + BaseAddress = new Uri("https://reloop.sh"), }; return (new ReloopClient("rl_test", "https://reloop.sh", httpClient), handler); @@ -22,18 +23,18 @@ public async Task CreateAsync_UsesApiKeyCreateRoute() { var (client, handler) = CreateClient(); - await client.ApiKeys.CreateAsync(new CreateApiKeyParams(Name: "Production Key")); + await client.ApiKey.CreateAsync(new CreateApiKeyParams("Production Key")); Assert.Equal(HttpMethod.Post, handler.LastRequest?.Method); Assert.Equal("/api/api-key/v1/", handler.LastRequest?.RequestUri?.PathAndQuery); } [Fact] - public async Task PauseAsync_UsesDisableRoute() + public async Task DisableAsync_UsesDisableRoute() { var (client, handler) = CreateClient(); - await client.ApiKeys.PauseAsync("key_1"); + await client.ApiKey.DisableAsync("key_1"); Assert.Equal(HttpMethod.Post, handler.LastRequest?.Method); Assert.Equal("/api/api-key/v1/disable/key_1", handler.LastRequest?.RequestUri?.PathAndQuery); @@ -44,9 +45,28 @@ public async Task RotateAsync_UsesRotateRoute() { var (client, handler) = CreateClient(); - await client.ApiKeys.RotateAsync("key_1"); + await client.ApiKey.RotateAsync("key_1"); Assert.Equal(HttpMethod.Post, handler.LastRequest?.Method); Assert.Equal("/api/api-key/v1/rotate/key_1", handler.LastRequest?.RequestUri?.PathAndQuery); } + + [Fact] + public async Task ListAsync_UsesQueryParams() + { + var (client, handler) = CreateClient(); + + await client.ApiKey.ListAsync(new ApiKeyListParams + { + Page = 2, + Limit = 10, + Enabled = true, + Q = "prod", + }); + + Assert.Equal(HttpMethod.Get, handler.LastRequest?.Method); + Assert.Equal( + "/api/api-key/v1/?page=2&limit=10&enabled=true&q=prod", + handler.LastRequest?.RequestUri?.PathAndQuery); + } } diff --git a/tests/Reloop.Tests/ApiKeyServiceTests.cs b/tests/Reloop.Tests/ApiKeyServiceTests.cs index b6ca9eb..3838feb 100644 --- a/tests/Reloop.Tests/ApiKeyServiceTests.cs +++ b/tests/Reloop.Tests/ApiKeyServiceTests.cs @@ -1,30 +1,104 @@ +using System.Net; +using System.Net.Http; using System.Text.Json; +using Reloop.Exceptions; using Reloop.Models; using Reloop.Services; using Xunit; +using static Reloop.Models.ApiKeyModels; namespace Reloop.Tests; public class ApiKeyServiceTests { + private static (ReloopClient Client, MockHttpMessageHandler Handler) CreateClient( + Action? configure = null) + { + var handler = new MockHttpMessageHandler(); + configure?.Invoke(handler); + var httpClient = new HttpClient(handler) + { + BaseAddress = new Uri("https://reloop.sh"), + }; + + return (new ReloopClient("rl_test", "https://reloop.sh", httpClient), handler); + } + [Fact] - public void CreateApiKeyParams_SerializesExpectedFields() + public void CreateApiKeyParams_SerializesNameOnly() { - var json = JsonSerializer.Serialize(new CreateApiKeyParams( - Name: "Production Key", - Enabled: true, - RateLimitEnabled: true - )); + var json = JsonSerializer.Serialize(new CreateApiKeyParams("Production Key")); Assert.Contains("\"name\":\"Production Key\"", json); - Assert.Contains("\"enabled\":true", json); - Assert.Contains("\"rateLimitEnabled\":true", json); + Assert.DoesNotContain("enabled", json); + } + + [Fact] + public async Task CreateAsync_HappyPath() + { + var (client, handler) = CreateClient(h => + { + h.ResponseBody = "{\"id\":\"key_1\",\"key\":\"rl_secret\",\"object\":\"api_key\"}"; + }); + + var response = await client.ApiKey.CreateAsync(new CreateApiKeyParams("prod")); + + Assert.Equal(HttpMethod.Post, handler.LastRequest?.Method); + Assert.Equal("/api/api-key/v1/", handler.LastRequest?.RequestUri?.PathAndQuery); + Assert.Contains("\"name\":\"prod\"", handler.LastRequestBody); + Assert.Equal("rl_secret", response!.Key); + } + + [Fact] + public async Task CreateAsync_ValidationNoHttp() + { + var (client, handler) = CreateClient(); + + await Assert.ThrowsAsync( + () => client.ApiKey.CreateAsync(new CreateApiKeyParams(""))); + + Assert.Equal(0, handler.RequestCount); + } + + [Fact] + public async Task GetAsync_ApiError() + { + var (client, _) = CreateClient(h => + { + h.ResponseStatusCode = HttpStatusCode.InternalServerError; + h.ResponseBody = "{\"message\":\"boom\"}"; + }); + + await Assert.ThrowsAsync(() => client.ApiKey.GetAsync("key_1")); + } + + [Fact] + public void SurfaceLock() + { + var methods = typeof(ApiKeyService) + .GetMethods(System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance) + .Where(m => m.DeclaringType == typeof(ApiKeyService)) + .Select(m => m.Name) + .ToHashSet(); + + Assert.Equal( + new HashSet + { + "CreateAsync", + "ListAsync", + "GetAsync", + "UpdateAsync", + "DeleteAsync", + "RotateAsync", + "EnableAsync", + "DisableAsync", + }, + methods); } [Fact] - public void PauseAsync_UsesSameRouteAsDisable() + public void PauseAsync_DoesNotExist() { - Assert.NotNull(typeof(ApiKeyService).GetMethod("PauseAsync")); - Assert.NotNull(typeof(ApiKeyService).GetMethod("DisableAsync")); + Assert.Null(typeof(ApiKeyService).GetMethod("PauseAsync")); } } diff --git a/tests/Reloop.Tests/ContactsServiceRouteTests.cs b/tests/Reloop.Tests/ContactsServiceRouteTests.cs index 455b5d2..eace3f6 100644 --- a/tests/Reloop.Tests/ContactsServiceRouteTests.cs +++ b/tests/Reloop.Tests/ContactsServiceRouteTests.cs @@ -1,5 +1,7 @@ using System.Net.Http; +using Reloop.Models; using Xunit; +using static Reloop.Models.ContactModels; namespace Reloop.Tests; @@ -8,11 +10,7 @@ public class ContactsServiceRouteTests private static (ReloopClient Client, MockHttpMessageHandler Handler) CreateClient() { var handler = new MockHttpMessageHandler(); - var httpClient = new HttpClient(handler) - { - BaseAddress = new Uri("https://reloop.sh") - }; - + var httpClient = new HttpClient(handler) { BaseAddress = new Uri("https://reloop.sh") }; return (new ReloopClient("rl_test", "https://reloop.sh", httpClient), handler); } @@ -20,54 +18,35 @@ private static (ReloopClient Client, MockHttpMessageHandler Handler) CreateClien public async Task CreateAsync_UsesContactsCreateRoute() { var (client, handler) = CreateClient(); - - await client.Contacts.CreateAsync(new Dictionary - { - ["email"] = "user@example.com", - ["first_name"] = "Ada", - }); - + await client.Contacts.CreateAsync(new CreateContactParams { Email = "user@example.com", FirstName = "Ada" }); Assert.Equal(HttpMethod.Post, handler.LastRequest?.Method); Assert.Equal("/api/contacts/create", handler.LastRequest?.RequestUri?.PathAndQuery); } [Fact] - public async Task GetAsync_UsesRetrieveRoute() + public async Task Groups_ListContactsAsync_UsesGroupContactsRoute() { var (client, handler) = CreateClient(); - - await client.Contacts.GetAsync("con_1"); - + await client.Contacts.Groups.ListContactsAsync("grp_1", new ListGroupContactsParams { Page = 1 }); Assert.Equal(HttpMethod.Get, handler.LastRequest?.Method); - Assert.Equal("/api/contacts/retrieve/con_1", handler.LastRequest?.RequestUri?.PathAndQuery); + Assert.Equal("/api/contacts/v1/groups/grp_1/contacts?page=1", handler.LastRequest?.RequestUri?.PathAndQuery); } [Fact] - public async Task ListAsync_WithGroupId_UsesGroupContactsRoute() + public async Task Channels_AddContactAsync_UsesChannelRoute() { var (client, handler) = CreateClient(); - - await client.Contacts.ListAsync(new Dictionary - { - ["groupId"] = "grp_1", - ["page"] = 1, - }); - - Assert.Equal(HttpMethod.Get, handler.LastRequest?.Method); - Assert.Equal("/api/contacts/v1/groups/grp_1/contacts?page=1", handler.LastRequest?.RequestUri?.PathAndQuery); + await client.Contacts.Channels.AddContactAsync("ch_1", new AddContactToChannelParams { ContactId = "con_1" }); + Assert.Equal(HttpMethod.Post, handler.LastRequest?.Method); + Assert.Equal("/api/contacts/channel/ch_1", handler.LastRequest?.RequestUri?.PathAndQuery); } [Fact] - public async Task ChannelsAddContactAsync_UsesChannelRoute() + public async Task Properties_CreateAsync_UsesPropertiesCreateRoute() { var (client, handler) = CreateClient(); - - await client.Contacts.Channels.AddContactAsync("ch_1", new Dictionary - { - ["contact_id"] = "con_1", - }); - + await client.Contacts.Properties.CreateAsync(new CreatePropertyParams { Name = "company", Type = PropertyType.String }); Assert.Equal(HttpMethod.Post, handler.LastRequest?.Method); - Assert.Equal("/api/contacts/channel/ch_1", handler.LastRequest?.RequestUri?.PathAndQuery); + Assert.Equal("/api/contacts/v1/properties/create", handler.LastRequest?.RequestUri?.PathAndQuery); } } diff --git a/tests/Reloop.Tests/ContactsServiceTests.cs b/tests/Reloop.Tests/ContactsServiceTests.cs new file mode 100644 index 0000000..64fce9e --- /dev/null +++ b/tests/Reloop.Tests/ContactsServiceTests.cs @@ -0,0 +1,108 @@ +using System.Net; +using System.Net.Http; +using Reloop.Exceptions; +using Reloop.Models; +using Reloop.Services; +using Xunit; +using static Reloop.Models.ContactModels; + +namespace Reloop.Tests; + +public class ContactsServiceTests +{ + private static (ReloopClient Client, MockHttpMessageHandler Handler) CreateClient( + Action? configure = null) + { + var handler = new MockHttpMessageHandler(); + configure?.Invoke(handler); + var httpClient = new HttpClient(handler) { BaseAddress = new Uri("https://reloop.sh") }; + return (new ReloopClient("rl_test", "https://reloop.sh", httpClient), handler); + } + + [Fact] + public async Task CreateAsync_HappyPath() + { + var (client, handler) = CreateClient(h => + { + h.ResponseBody = + "{\"object\":\"contact\",\"id\":\"con_1\",\"email\":\"john@example.com\",\"status\":\"subscribed\",\"event\":\"contact.created\"}"; + }); + + var response = await client.Contacts.CreateAsync(new CreateContactParams + { + Email = "john@example.com", + FirstName = "John", + Status = ContactStatus.Subscribed, + }); + + Assert.Equal(HttpMethod.Post, handler.LastRequest?.Method); + Assert.Equal("/api/contacts/create", handler.LastRequest?.RequestUri?.PathAndQuery); + Assert.Contains("\"email\":\"john@example.com\"", handler.LastRequestBody); + Assert.Equal("con_1", response!.Id); + } + + [Fact] + public async Task CreateAsync_ValidationNoHttp() + { + var (client, handler) = CreateClient(); + await Assert.ThrowsAsync(() => client.Contacts.CreateAsync(new CreateContactParams + { + Email = "not-an-email", + })); + Assert.Equal(0, handler.RequestCount); + } + + [Fact] + public async Task UpdateAsync_ValidationNoHttp() + { + var (client, handler) = CreateClient(); + await Assert.ThrowsAsync( + () => client.Contacts.UpdateAsync("con_1", new UpdateContactParams())); + Assert.Equal(0, handler.RequestCount); + } + + [Fact] + public void SurfaceLock() + { + var methods = typeof(ContactsService) + .GetMethods(System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance) + .Where(m => m.DeclaringType == typeof(ContactsService) && !m.IsSpecialName) + .Select(m => m.Name) + .ToHashSet(); + + Assert.Equal( + new HashSet { "CreateAsync", "DeleteAsync", "GetAsync", "ListAsync", "UpdateAsync" }, + methods); + } + + [Fact] + public async Task UpdateAsync_AllowsDigitPropertyKeys() + { + var (client, handler) = CreateClient(); + await client.Contacts.UpdateAsync("con_1", new UpdateContactParams + { + Properties = new Dictionary { ["utm2"] = "ads" }, + }); + Assert.Contains("\"utm2\":\"ads\"", handler.LastRequestBody); + } + + [Fact] + public async Task Channels_UpdateAsync_PreservesNullDescriptionClear() + { + var (client, handler) = CreateClient(); + await client.Contacts.Channels.UpdateAsync("chn_1", new UpdateChannelParams + { + DescriptionPresent = true, + DescriptionClear = true, + }); + Assert.Contains("\"description\":null", handler.LastRequestBody); + } + + [Fact] + public async Task Properties_UpdateAsync_SendsNullFallbackValue() + { + var (client, handler) = CreateClient(); + await client.Contacts.Properties.UpdateAsync("prop_1", new UpdatePropertyParams { FallbackValue = null }); + Assert.Contains("\"fallbackValue\":null", handler.LastRequestBody); + } +} diff --git a/tests/Reloop.Tests/DomainServiceRouteTests.cs b/tests/Reloop.Tests/DomainServiceRouteTests.cs index 3f424af..7029ba3 100644 --- a/tests/Reloop.Tests/DomainServiceRouteTests.cs +++ b/tests/Reloop.Tests/DomainServiceRouteTests.cs @@ -1,7 +1,7 @@ using System.Net.Http; using Reloop.Models; -using Reloop.Services; using Xunit; +using static Reloop.Models.DomainModels; namespace Reloop.Tests; @@ -12,7 +12,7 @@ private static (ReloopClient Client, MockHttpMessageHandler Handler) CreateClien var handler = new MockHttpMessageHandler(); var httpClient = new HttpClient(handler) { - BaseAddress = new Uri("https://reloop.sh") + BaseAddress = new Uri("https://reloop.sh"), }; return (new ReloopClient("rl_test", "https://reloop.sh", httpClient), handler); @@ -23,48 +23,53 @@ public async Task CreateAsync_UsesDomainCreateRoute() { var (client, handler) = CreateClient(); - await client.Domain.CreateAsync(new CreateDomainParams( - Domain: "send.example.com", - ClickTracking: true, - CustomReturnPath: "inbound")); + await client.Domain.CreateAsync(new CreateDomainParams("send.example.com") + { + ClickTracking = true, + }); Assert.Equal(HttpMethod.Post, handler.LastRequest?.Method); Assert.Equal("/api/domain/v1/create", handler.LastRequest?.RequestUri?.PathAndQuery); } [Fact] - public async Task GetNameserversAsync_UsesNameserversRoute() + public async Task ListAsync_UsesListRouteWithQuery() { var (client, handler) = CreateClient(); - await client.Domain.GetNameserversAsync("dom_1"); + await client.Domain.ListAsync(new ListDomainsParams + { + Page = 2, + Limit = 5, + Q = "example", + Status = "active", + }); Assert.Equal(HttpMethod.Get, handler.LastRequest?.Method); - Assert.Equal("/api/domain/v1/nameservers/dom_1", handler.LastRequest?.RequestUri?.PathAndQuery); + Assert.Equal( + "/api/domain/v1/list?page=2&limit=5&q=example&status=active", + handler.LastRequest?.RequestUri?.PathAndQuery); } [Fact] - public async Task ForwardDnsAsync_UsesForwardDnsRoute() + public async Task VerifyAsync_UsesVerifyRoute() { var (client, handler) = CreateClient(); - await client.Domain.ForwardDnsAsync("dom_1", new ForwardDnsParams(Email: "admin@example.com")); + await client.Domain.VerifyAsync("dom_1"); Assert.Equal(HttpMethod.Post, handler.LastRequest?.Method); - Assert.Equal("/api/domain/v1/verify/dom_1/forward-dns", handler.LastRequest?.RequestUri?.PathAndQuery); + Assert.Equal("/api/domain/v1/verify/dom_1", handler.LastRequest?.RequestUri?.PathAndQuery); } [Fact] - public void BuildListQuery_IncludesFilters() + public async Task DeleteAsync_UsesDeleteRoute() { - var query = DomainService.BuildListQuery(new ListDomainsParams - { - Page = 2, - Limit = 5, - Q = "example", - Status = "active", - }); + var (client, handler) = CreateClient(); + + await client.Domain.DeleteAsync("dom_1"); - Assert.Equal("page=2&limit=5&q=example&status=active", query); + Assert.Equal(HttpMethod.Delete, handler.LastRequest?.Method); + Assert.Equal("/api/domain/v1/dom_1", handler.LastRequest?.RequestUri?.PathAndQuery); } } diff --git a/tests/Reloop.Tests/DomainServiceTests.cs b/tests/Reloop.Tests/DomainServiceTests.cs index b6244bd..a9b0d86 100644 --- a/tests/Reloop.Tests/DomainServiceTests.cs +++ b/tests/Reloop.Tests/DomainServiceTests.cs @@ -1,48 +1,123 @@ +using System.Net; +using System.Net.Http; using System.Text.Json; +using Reloop.Exceptions; using Reloop.Models; using Reloop.Services; using Xunit; +using static Reloop.Models.DomainModels; namespace Reloop.Tests; public class DomainServiceTests { + private static (ReloopClient Client, MockHttpMessageHandler Handler) CreateClient( + Action? configure = null) + { + var handler = new MockHttpMessageHandler(); + configure?.Invoke(handler); + var httpClient = new HttpClient(handler) + { + BaseAddress = new Uri("https://reloop.sh"), + }; + + return (new ReloopClient("rl_test", "https://reloop.sh", httpClient), handler); + } + [Fact] public void CreateDomainParams_SerializesWithSnakeCase() { - var json = JsonSerializer.Serialize(new CreateDomainParams( - Domain: "send.example.com", - CustomReturnPath: "inbound", - ClickTracking: true)); + var json = JsonSerializer.Serialize(new CreateDomainParams("send.example.com") + { + ClickTracking = true, + }); Assert.Contains("\"click_tracking\":true", json); - Assert.Contains("\"custom_return_path\":\"inbound\"", json); + Assert.Contains("\"domain\":\"send.example.com\"", json); Assert.DoesNotContain("clickTracking", json); + Assert.DoesNotContain("custom_return_path", json); } [Fact] public void UpdateDomainParams_SerializesWithSnakeCase() { - var json = JsonSerializer.Serialize(new UpdateDomainParams( - ClickTracking: false, - OpenTracking: true, - Tls: "enforced")); + var json = JsonSerializer.Serialize(new UpdateDomainParams + { + ClickTracking = false, + OpenTracking = true, + Tls = "enforced", + }); Assert.Contains("\"click_tracking\":false", json); Assert.DoesNotContain("clickTracking", json); } [Fact] - public void BuildListQuery_IncludesFilters() + public async Task CreateAsync_HappyPath() + { + var (client, handler) = CreateClient(h => + { + h.ResponseBody = "{\"id\":\"dom_1\",\"domain\":\"example.com\",\"object\":\"domain\"}"; + }); + + var response = await client.Domain.CreateAsync(new CreateDomainParams("example.com")); + + Assert.Equal(HttpMethod.Post, handler.LastRequest?.Method); + Assert.Equal("/api/domain/v1/create", handler.LastRequest?.RequestUri?.PathAndQuery); + Assert.Contains("\"domain\":\"example.com\"", handler.LastRequestBody); + Assert.Equal("dom_1", response!.Id); + } + + [Fact] + public async Task GetAsync_ValidationNoHttp() + { + var (client, handler) = CreateClient(); + + await Assert.ThrowsAsync(() => client.Domain.GetAsync(" ")); + await Assert.ThrowsAsync(() => client.Domain.GetAsync("../admin")); + await Assert.ThrowsAsync(() => client.Domain.DeleteAsync("dom?x=1")); + + Assert.Equal(0, handler.RequestCount); + } + + [Fact] + public async Task GetAsync_ApiError() { - var query = DomainService.BuildListQuery(new ListDomainsParams + var (client, _) = CreateClient(h => { - Page = 2, - Limit = 5, - Q = "example", - Status = "active", + h.ResponseStatusCode = HttpStatusCode.NotFound; + h.ResponseBody = "{\"message\":\"missing\"}"; }); - Assert.Equal("page=2&limit=5&q=example&status=active", query); + await Assert.ThrowsAsync(() => client.Domain.GetAsync("dom_missing")); + } + + [Fact] + public void SurfaceLock() + { + var methods = typeof(DomainService) + .GetMethods(System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance) + .Where(m => m.DeclaringType == typeof(DomainService)) + .Select(m => m.Name) + .ToHashSet(); + + Assert.Equal( + new HashSet + { + "CreateAsync", + "ListAsync", + "GetAsync", + "UpdateAsync", + "DeleteAsync", + "VerifyAsync", + }, + methods); + } + + [Fact] + public void LegacyMethods_DoNotExist() + { + Assert.Null(typeof(DomainService).GetMethod("GetNameserversAsync")); + Assert.Null(typeof(DomainService).GetMethod("ForwardDnsAsync")); } } diff --git a/tests/Reloop.Tests/InboxServiceTests.cs b/tests/Reloop.Tests/InboxServiceTests.cs new file mode 100644 index 0000000..42a67f3 --- /dev/null +++ b/tests/Reloop.Tests/InboxServiceTests.cs @@ -0,0 +1,204 @@ +using System.Net; +using System.Net.Http; +using Reloop.Exceptions; +using Reloop.Models; +using Reloop.Services; +using Xunit; +using static Reloop.Models.InboxModels; + +namespace Reloop.Tests; + +public class InboxMailboxesServiceTests +{ + private static (ReloopClient Client, MockHttpMessageHandler Handler) CreateClient( + Action? configure = null) + { + var handler = new MockHttpMessageHandler(); + configure?.Invoke(handler); + var httpClient = new HttpClient(handler) { BaseAddress = new Uri("https://reloop.sh") }; + return (new ReloopClient("rl_test", "https://reloop.sh", httpClient), handler); + } + + [Fact] + public async Task CreateAsync_HappyPath() + { + var (client, handler) = CreateClient(h => + { + h.ResponseBody = "{\"id\":\"mbx_1\",\"email\":\"user@example.com\",\"status\":\"active\"}"; + }); + + var response = await client.Inbox.Mailboxes.CreateAsync(new CreateMailboxParams + { + DomainId = "dom_1", + Email = "user@example.com", + }); + + Assert.Equal(HttpMethod.Post, handler.LastRequest?.Method); + Assert.Equal("/api/inbox/v1/mailboxes/create", handler.LastRequest?.RequestUri?.PathAndQuery); + Assert.Equal("mbx_1", response!.Id); + } + + [Fact] + public async Task GetAsync_ValidationNoHttp() + { + var (client, handler) = CreateClient(); + var err = await Assert.ThrowsAsync(() => client.Inbox.Mailboxes.GetAsync(" ")); + Assert.Equal("id", err.Field); + Assert.Equal(0, handler.RequestCount); + } + + [Fact] + public void SurfaceLock() + { + var methods = typeof(InboxMailboxesService) + .GetMethods(System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance) + .Where(m => m.DeclaringType == typeof(InboxMailboxesService)) + .Select(m => m.Name) + .ToHashSet(); + + Assert.Equal(new HashSet { "CreateAsync", "DeleteAsync", "GetAsync", "ListAsync", "UpdateAsync" }, methods); + } +} + +public class InboxMessagesServiceTests +{ + private static (ReloopClient Client, MockHttpMessageHandler Handler) CreateClient( + Action? configure = null) + { + var handler = new MockHttpMessageHandler(); + configure?.Invoke(handler); + var httpClient = new HttpClient(handler) { BaseAddress = new Uri("https://reloop.sh") }; + return (new ReloopClient("rl_test", "https://reloop.sh", httpClient), handler); + } + + [Fact] + public async Task SendAsync_HappyPath() + { + var (client, handler) = CreateClient(h => + { + h.ResponseBody = + "{\"success\":true,\"messageId\":\"msg_1\",\"status\":\"queued\",\"timestamp\":\"t\",\"id\":\"em_1\"}"; + }); + + var response = await client.Inbox.Messages.SendAsync(new SendMessageParams + { + MailboxId = "mbx_1", + To = "user@example.com", + Subject = "Hello", + Html = "

Hi

", + }); + + Assert.Equal(HttpMethod.Post, handler.LastRequest?.Method); + Assert.Equal("/api/inbox/v1/messages/send", handler.LastRequest?.RequestUri?.PathAndQuery); + Assert.True(response!.Success); + Assert.Equal("em_1", response.Id); + } + + [Fact] + public async Task SendAsync_ValidationNoHttp() + { + var (client, handler) = CreateClient(); + var err = await Assert.ThrowsAsync(() => client.Inbox.Messages.SendAsync(new SendMessageParams + { + MailboxId = "", + To = "user@example.com", + Subject = "Hello", + })); + Assert.Equal("mailboxId", err.Field); + + var bodyErr = await Assert.ThrowsAsync(() => client.Inbox.Messages.SendAsync(new SendMessageParams + { + MailboxId = "mbx_1", + To = "user@example.com", + Subject = "Hello", + })); + Assert.Equal("params", bodyErr.Field); + Assert.Equal(0, handler.RequestCount); + } + + [Fact] + public async Task SetReadAsync_DefaultsToTrue() + { + var (client, handler) = CreateClient(); + await client.Inbox.Messages.SetReadAsync("msg_1"); + Assert.Contains("\"isRead\":true", handler.LastRequestBody); + } + + [Fact] + public void SurfaceLock() + { + var methods = typeof(InboxMessagesService) + .GetMethods(System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance) + .Where(m => m.DeclaringType == typeof(InboxMessagesService)) + .Select(m => m.Name) + .ToHashSet(); + + Assert.Equal( + new HashSet + { + "BatchAsync", "CancelPendingAsync", "DeleteAsync", "ForwardAsync", "GetAsync", "GetAttachmentAsync", + "GetRawAsync", "ListAsync", "ListSentAsync", "ReplyAllAsync", "ReplyAsync", "SendAsync", "SetReadAsync", + "SetStarAsync", "UpdateAsync", + }, + methods); + } +} + +public class InboxThreadsServiceTests +{ + private static (ReloopClient Client, MockHttpMessageHandler Handler) CreateClient( + Action? configure = null) + { + var handler = new MockHttpMessageHandler(); + configure?.Invoke(handler); + var httpClient = new HttpClient(handler) { BaseAddress = new Uri("https://reloop.sh") }; + return (new ReloopClient("rl_test", "https://reloop.sh", httpClient), handler); + } + + [Fact] + public async Task ListAsync_HappyPath() + { + var (client, handler) = CreateClient(h => + { + h.ResponseBody = + "[{\"id\":\"thr_1\",\"organizationId\":\"org_1\",\"lastMessageAt\":\"t\",\"status\":\"active\"}]"; + }); + + var response = await client.Inbox.Threads.ListAsync(new ListThreadsParams { Limit = 50 }); + Assert.Equal(HttpMethod.Get, handler.LastRequest?.Method); + Assert.Equal("/api/inbox/v1/threads?limit=50", handler.LastRequest?.RequestUri?.PathAndQuery); + Assert.Single(response!); + Assert.Equal("thr_1", response![0].Id); + } + + [Fact] + public async Task BatchAsync_ValidationNoHttp() + { + var (client, handler) = CreateClient(); + var err = await Assert.ThrowsAsync(() => client.Inbox.Threads.BatchAsync(new BatchThreadsParams + { + Ids = new List(), + Action = ThreadBatchAction.Archive, + })); + Assert.Equal("ids", err.Field); + Assert.Equal(0, handler.RequestCount); + } + + [Fact] + public void SurfaceLock() + { + var methods = typeof(InboxThreadsService) + .GetMethods(System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance) + .Where(m => m.DeclaringType == typeof(InboxThreadsService)) + .Select(m => m.Name) + .ToHashSet(); + + Assert.Equal( + new HashSet + { + "ArchiveAsync", "BatchAsync", "DeleteAsync", "GetAsync", "GetAttachmentAsync", "ListAsync", + "RestoreAsync", "SetReadAsync", "SetStarAsync", "TrashAsync", "UpdateAsync", + }, + methods); + } +} diff --git a/tests/Reloop.Tests/MailServiceRouteTests.cs b/tests/Reloop.Tests/MailServiceRouteTests.cs index dd92664..bd19307 100644 --- a/tests/Reloop.Tests/MailServiceRouteTests.cs +++ b/tests/Reloop.Tests/MailServiceRouteTests.cs @@ -1,7 +1,6 @@ -using System.Collections.Generic; -using System.Net.Http; -using Reloop.Services; +using Reloop.Models; using Xunit; +using static Reloop.Models.MailModels; namespace Reloop.Tests; @@ -12,7 +11,7 @@ private static (ReloopClient Client, MockHttpMessageHandler Handler) CreateClien var handler = new MockHttpMessageHandler(); var httpClient = new HttpClient(handler) { - BaseAddress = new Uri("https://reloop.sh") + BaseAddress = new Uri("https://reloop.sh"), }; return (new ReloopClient("rl_test", "https://reloop.sh", httpClient), handler); @@ -23,12 +22,12 @@ public async Task SendAsync_UsesMailSendRoute() { var (client, handler) = CreateClient(); - await client.Mail.SendAsync(new Dictionary + await client.Mail.SendAsync(new SendMailParams { - ["from"] = "Reloop ", - ["to"] = "user@example.com", - ["subject"] = "Welcome to Reloop", - ["reply_to"] = "support@example.com", + From = "Reloop ", + To = "user@example.com", + Subject = "Welcome to Reloop", + ReplyTo = "support@example.com", }); Assert.Equal(HttpMethod.Post, handler.LastRequest?.Method); diff --git a/tests/Reloop.Tests/MailServiceTests.cs b/tests/Reloop.Tests/MailServiceTests.cs new file mode 100644 index 0000000..d545c70 --- /dev/null +++ b/tests/Reloop.Tests/MailServiceTests.cs @@ -0,0 +1,93 @@ +using Reloop.Exceptions; +using Reloop.Models; +using Reloop.Services; +using Xunit; +using static Reloop.Models.MailModels; + +namespace Reloop.Tests; + +public class MailServiceTests +{ + private static (ReloopClient Client, MockHttpMessageHandler Handler) CreateClient( + Action? configure = null) + { + var handler = new MockHttpMessageHandler(); + configure?.Invoke(handler); + var httpClient = new HttpClient(handler) + { + BaseAddress = new Uri("https://reloop.sh"), + }; + + return (new ReloopClient("rl_test", "https://reloop.sh", httpClient), handler); + } + + [Fact] + public async Task SendAsync_HappyPath() + { + var (client, handler) = CreateClient(h => + { + h.ResponseBody = + "{\"success\":true,\"messageId\":\"msg_1\",\"status\":\"queued\",\"timestamp\":\"t\",\"id\":\"em_1\"}"; + }); + + var response = await client.Mail.SendAsync(new SendMailParams + { + From = "a@example.com", + To = "b@example.com", + Subject = "Hi", + }); + + Assert.Equal(HttpMethod.Post, handler.LastRequest?.Method); + Assert.Equal("/api/mail/v1/send", handler.LastRequest?.RequestUri?.PathAndQuery); + Assert.Equal("rl_test", handler.LastRequest?.Headers.GetValues("x-api-key").Single()); + Assert.Contains("\"from\":\"a@example.com\"", handler.LastRequestBody); + Assert.NotNull(response); + Assert.True(response!.Success); + Assert.Equal("em_1", response.Id); + } + + [Fact] + public async Task SendAsync_ApiError() + { + var (client, _) = CreateClient(h => + { + h.ResponseStatusCode = System.Net.HttpStatusCode.BadRequest; + h.ResponseBody = "{\"message\":\"invalid\"}"; + }); + + await Assert.ThrowsAsync(() => client.Mail.SendAsync(new SendMailParams + { + From = "a@x.com", + To = "b@x.com", + Subject = "Hi", + })); + } + + [Fact] + public async Task SendAsync_ValidationFailureDoesNotCallHttp() + { + var (client, handler) = CreateClient(); + + var err = await Assert.ThrowsAsync(() => client.Mail.SendAsync(new SendMailParams + { + From = "", + To = "b@x.com", + Subject = "Hi", + })); + + Assert.Equal("from", err.Field); + Assert.Equal(0, handler.RequestCount); + } + + [Fact] + public void SurfaceLock() + { + var methods = typeof(MailService) + .GetMethods(System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance) + .Where(m => m.DeclaringType == typeof(MailService)) + .Select(m => m.Name) + .ToHashSet(); + + Assert.Equal(new HashSet { "SendAsync" }, methods); + } +} diff --git a/tests/Reloop.Tests/MockHttpMessageHandler.cs b/tests/Reloop.Tests/MockHttpMessageHandler.cs index 3cea009..ee07385 100644 --- a/tests/Reloop.Tests/MockHttpMessageHandler.cs +++ b/tests/Reloop.Tests/MockHttpMessageHandler.cs @@ -7,17 +7,25 @@ namespace Reloop.Tests; internal sealed class MockHttpMessageHandler : HttpMessageHandler { + public HttpStatusCode ResponseStatusCode { get; set; } = HttpStatusCode.OK; + public string ResponseBody { get; set; } = "{\"id\":\"test_1\"}"; public HttpRequestMessage? LastRequest { get; private set; } + public string? LastRequestBody { get; private set; } + public int RequestCount { get; private set; } - protected override Task SendAsync( + protected override async Task SendAsync( HttpRequestMessage request, CancellationToken cancellationToken) { LastRequest = request; + LastRequestBody = request.Content == null + ? null + : await request.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); + RequestCount++; - return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + return new HttpResponseMessage(ResponseStatusCode) { - Content = new StringContent("{\"id\":\"test_1\"}") - }); + Content = new StringContent(ResponseBody), + }; } } diff --git a/tests/Reloop.Tests/ReloopClientTests.cs b/tests/Reloop.Tests/ReloopClientTests.cs new file mode 100644 index 0000000..9953321 --- /dev/null +++ b/tests/Reloop.Tests/ReloopClientTests.cs @@ -0,0 +1,134 @@ +using System.Net; +using System.Net.Http; +using Reloop.Exceptions; +using Reloop.Services; +using Xunit; + +namespace Reloop.Tests; + +public class ReloopClientTests +{ + private static (ReloopClient Client, MockHttpMessageHandler Handler) CreateClient( + Action? configure = null) + { + var handler = new MockHttpMessageHandler(); + configure?.Invoke(handler); + var httpClient = new HttpClient(handler) + { + BaseAddress = new Uri("https://reloop.sh"), + }; + + return (new ReloopClient("rl_test", "https://reloop.sh", httpClient), handler); + } + + [Fact] + public void Constructor_RequiresApiKey() + { + Assert.Throws(() => new ReloopClient(null!)); + Assert.Throws(() => new ReloopClient(" ")); + } + + [Fact] + public void Constructor_WiresServicesAndDefaultBaseUrl() + { + using var client = new ReloopClient("rl_test"); + + Assert.Equal("https://reloop.sh", client.BaseUrl); + Assert.NotNull(client.ApiKey); + Assert.NotNull(client.Mail); + Assert.NotNull(client.Domain); + Assert.NotNull(client.Contacts); + Assert.NotNull(client.Webhook); + Assert.NotNull(client.Inbox); + Assert.Equal("2.0.0", Version.VERSION); + } + + [Fact] + public void Constructor_TrimsTrailingSlashesFromBaseUrl() + { + using var client = new ReloopClient("rl_test", "https://reloop.sh/"); + + Assert.Equal("https://reloop.sh", client.BaseUrl); + } + + [Fact] + public async Task FetchAsync_SetsAuthHeader() + { + var (client, handler) = CreateClient(); + await client.FetchAsync(HttpMethod.Get, "/v1/test"); + + Assert.Equal("rl_test", handler.LastRequest?.Headers.GetValues("x-api-key").Single()); + } + + [Fact] + public async Task FetchAsync_MapsApiErrorToReloopApiException() + { + var (client, _) = CreateClient(handler => + { + handler.ResponseStatusCode = HttpStatusCode.Unauthorized; + handler.ResponseBody = "{\"message\":\"bad key\"}"; + }); + + var err = await Assert.ThrowsAsync( + () => client.FetchAsync(HttpMethod.Get, "/v1/test")); + + Assert.Equal(401, err.Status); + Assert.Equal("bad key", err.Body.Message); + } + + [Fact] + public async Task FetchAsync_NetworkErrorBecomesReloopApiException() + { + using var client = new ReloopClient("rl_test", "http://127.0.0.1:1"); + + var err = await Assert.ThrowsAsync( + () => client.FetchAsync(HttpMethod.Get, "/v1/test")); + + Assert.Equal(0, err.Status); + Assert.Contains("network", err.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task FetchAsync_MalformedSuccessJsonBecomesReloopApiException() + { + var (client, _) = CreateClient(handler => + { + handler.ResponseBody = "{not-json"; + }); + + var err = await Assert.ThrowsAsync( + () => client.FetchAsync(HttpMethod.Get, "/v1/test")); + + Assert.Contains("parsing", err.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task Constructor_DoesNotMutateSharedHttpClientDefaults() + { + var handler = new MockHttpMessageHandler(); + var shared = new HttpClient(handler) { BaseAddress = new Uri("https://reloop.sh") }; + using var clientA = new ReloopClient("rl_a", "https://reloop.sh", shared); + using var clientB = new ReloopClient("rl_b", "https://reloop.sh", shared); + + await clientA.FetchAsync(HttpMethod.Get, "/v1/a"); + Assert.Equal("rl_a", handler.LastRequest!.Headers.GetValues("x-api-key").Single()); + + await clientB.FetchAsync(HttpMethod.Get, "/v1/b"); + Assert.Equal("rl_b", handler.LastRequest!.Headers.GetValues("x-api-key").Single()); + + Assert.False(shared.DefaultRequestHeaders.Contains("x-api-key")); + } + + [Fact] + public async Task FetchAsync_RejectsAbsoluteUrls() + { + var (client, handler) = CreateClient(); + + await Assert.ThrowsAsync( + () => client.FetchAsync(HttpMethod.Get, "https://evil.example/steal")); + await Assert.ThrowsAsync( + () => client.FetchAsync(HttpMethod.Get, "//evil.example/steal")); + + Assert.Equal(0, handler.RequestCount); + } +} diff --git a/tests/Reloop.Tests/WebhookServiceTests.cs b/tests/Reloop.Tests/WebhookServiceTests.cs new file mode 100644 index 0000000..a6a7565 --- /dev/null +++ b/tests/Reloop.Tests/WebhookServiceTests.cs @@ -0,0 +1,190 @@ +using System.Net; +using System.Net.Http; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using Reloop.Exceptions; +using Reloop.Models; +using Reloop.Services; +using Xunit; +using static Reloop.Models.WebhookModels; + +namespace Reloop.Tests; + +public class WebhookServiceTests +{ + private const string Secret = "whsec_test_secret"; + private const string Payload = + "{\"id\":\"evt_123456789\",\"event\":\"domain.created\",\"payload\":{\"domainId\":\"dom_1\"},\"timestamp\":1735689600}"; + + private static (ReloopClient Client, MockHttpMessageHandler Handler) CreateClient( + Action? configure = null) + { + var handler = new MockHttpMessageHandler(); + configure?.Invoke(handler); + var httpClient = new HttpClient(handler) { BaseAddress = new Uri("https://reloop.sh") }; + return (new ReloopClient("rl_test", "https://reloop.sh", httpClient), handler); + } + + [Fact] + public async Task CreateAsync_HappyPath() + { + var (client, handler) = CreateClient(h => + { + h.ResponseBody = + "{\"id\":\"wh_123456789\",\"name\":\"Production webhook\",\"url\":\"https://example.com/webhooks/reloop\",\"secret\":\"whsec_test\",\"status\":\"active\",\"rateLimitEnabled\":false,\"maxRequestsPerMinute\":60,\"maxRetries\":3,\"retryBackoffMultiplier\":2,\"successCount\":0,\"failureCount\":0,\"consecutiveFailures\":0,\"events\":[\"domain.created\"],\"createdAt\":\"2026-01-01T00:00:00.000Z\",\"updatedAt\":\"2026-01-01T00:00:00.000Z\"}"; + }); + + var response = await client.Webhook.CreateAsync(new CreateWebhookParams( + "Production webhook", + "https://example.com/webhooks/reloop", + new List { "domain.created" })); + + Assert.Equal(HttpMethod.Post, handler.LastRequest?.Method); + Assert.Equal("/api/webhook/v1/", handler.LastRequest?.RequestUri?.PathAndQuery); + Assert.Equal("wh_123456789", response!.Id); + } + + [Fact] + public async Task Validation_NoHttp() + { + var (client, handler) = CreateClient(); + await Assert.ThrowsAsync(() => client.Webhook.GetAsync("")); + await Assert.ThrowsAsync( + () => client.Webhook.UpdateAsync("wh_1", new UpdateWebhookParams())); + await Assert.ThrowsAsync(() => client.Webhook.TriggerAsync(new TriggerWebhookParams())); + await Assert.ThrowsAsync( + () => client.Webhook.CreateAsync(new CreateWebhookParams())); + await Assert.ThrowsAsync( + () => client.Webhook.CreateAsync(new CreateWebhookParams + { + Url = "https://example.com/hook", + Events = new List(), + })); + await Assert.ThrowsAsync( + () => client.Webhook.ListDeliveriesAsync("wh_1", new ListWebhookDeliveriesParams { Status = "" })); + Assert.Equal(0, handler.RequestCount); + } + + [Fact] + public async Task PauseAsync_PatchesPausedStatus() + { + var (client, handler) = CreateClient(); + await client.Webhook.PauseAsync("wh_1"); + Assert.Equal(new HttpMethod("PATCH"), handler.LastRequest?.Method); + Assert.Contains("\"status\":\"paused\"", handler.LastRequestBody); + } + + [Fact] + public void Verify_AcceptsValidSignature() + { + var timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString(); + var header = SignPayload(Secret, timestamp, Payload); + var parameters = new VerifyWebhookParams + { + Payload = Encoding.UTF8.GetBytes(Payload), + Headers = new Dictionary { [WebhookVerify.WebhookSignatureHeader] = header }, + Secret = Secret, + }; + + var webhookEvent = WebhookVerify.Verify(parameters); + Assert.Equal("evt_123456789", webhookEvent.Id); + Assert.Equal("domain.created", webhookEvent.Event); + Assert.Equal("dom_1", GetPayloadString(webhookEvent.Payload!, "domainId")); + } + + [Fact] + public void ConstructEvent_VerifiesSignature() + { + var timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString(); + var header = SignPayload(Secret, timestamp, Payload); + var webhookEvent = WebhookService.ConstructEvent(Encoding.UTF8.GetBytes(Payload), header, Secret, 300); + Assert.Equal("domain.created", webhookEvent.Event); + } + + [Fact] + public void Verify_RejectsWrongSecret() + { + var timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString(); + var header = SignPayload(Secret, timestamp, Payload); + var parameters = new VerifyWebhookParams + { + Payload = Encoding.UTF8.GetBytes(Payload), + Headers = new Dictionary { [WebhookVerify.WebhookSignatureHeader] = header }, + Secret = "wrong_secret", + }; + + Assert.Throws(() => WebhookVerify.Verify(parameters)); + } + + [Fact] + public void Verify_RejectsExpiredTimestamp() + { + var timestamp = (DateTimeOffset.UtcNow.ToUnixTimeSeconds() - 600).ToString(); + var header = SignPayload(Secret, timestamp, Payload); + var parameters = new VerifyWebhookParams + { + Payload = Encoding.UTF8.GetBytes(Payload), + Headers = new Dictionary { [WebhookVerify.WebhookSignatureHeader] = header }, + Secret = Secret, + Tolerance = 300, + }; + + var err = Assert.Throws(() => WebhookVerify.Verify(parameters)); + Assert.Contains("tolerance", err.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void Verify_RejectsNegativeTolerance() + { + var timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString(); + var header = SignPayload(Secret, timestamp, Payload); + var parameters = new VerifyWebhookParams + { + Payload = Encoding.UTF8.GetBytes(Payload), + Headers = new Dictionary { [WebhookVerify.WebhookSignatureHeader] = header }, + Secret = Secret, + Tolerance = -1, + }; + + var err = Assert.Throws(() => WebhookVerify.Verify(parameters)); + Assert.Contains("non-negative", err.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void SurfaceLock() + { + var instanceMethods = typeof(WebhookService) + .GetMethods(System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance) + .Where(m => m.DeclaringType == typeof(WebhookService)) + .Select(m => m.Name) + .ToHashSet(); + + Assert.Equal( + new HashSet + { + "CreateAsync", "DeleteAsync", "DisableAsync", "EnableAsync", "GetAsync", "ListAsync", + "ListDeliveriesAsync", "PauseAsync", "RetryDeliveryAsync", "TriggerAsync", "UpdateAsync", "Verify", + }, + instanceMethods); + + Assert.NotNull(typeof(WebhookService).GetMethod("ConstructEvent")); + } + + private static string SignPayload(string secret, string timestamp, string payload) + { + using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret)); + var hash = hmac.ComputeHash(Encoding.UTF8.GetBytes(timestamp + "." + payload)); + return "t=" + timestamp + ",v1=" + BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant(); + } + + private static string? GetPayloadString(Dictionary payload, string key) + { + if (!payload.TryGetValue(key, out var value)) + { + return null; + } + + return value is JsonElement element ? element.GetString() : value?.ToString(); + } +}