diff --git a/.envrc b/.envrc new file mode 100644 index 00000000..8b808bbd --- /dev/null +++ b/.envrc @@ -0,0 +1,4 @@ +export MAILTRAP_ACCOUNT_ID="op://Mailtrap Dev/Mailtrap SDK Dev API Key/account_id" +export MAILTRAP_ORGANIZATION_ID="op://Mailtrap Dev/Mailtrap SDK Dev API Key/organization_id" +export MAILTRAP_API_KEY="op://Mailtrap Dev/Mailtrap SDK Dev API Key/account_api_token" +export MAILTRAP_ORGANIZATION_API_KEY="op://Mailtrap Dev/Mailtrap SDK Dev API Key/organization_api_token" diff --git a/CHANGELOG.md b/CHANGELOG.md index 36ee03d0..dcff975d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,19 @@ # Changelog +## [Unreleased] + +### Features + +- **API Tokens API** — Added full CRUD support: + - `IAccountResource.ApiTokens().GetAll()` — `GET /api/accounts/{account_id}/api_tokens` + - `IAccountResource.ApiTokens().Create(request)` — `POST /api/accounts/{account_id}/api_tokens` + - `IAccountResource.ApiToken(id).GetDetails()` — `GET /api/accounts/{account_id}/api_tokens/{id}` + - `IAccountResource.ApiToken(id).Delete()` — `DELETE /api/accounts/{account_id}/api_tokens/{id}` + - `IAccountResource.ApiToken(id).Reset()` — `POST /api/accounts/{account_id}/api_tokens/{id}/reset` +- **Organizations API** — Added new top-level `IMailtrapOrganizationClient` (spawned via `MailtrapClientFactory.CreateOrganizationClient()`) for organization-scoped operations: + - `IOrganizationResource.SubAccounts().GetAll()` — `GET /api/organizations/{organization_id}/sub_accounts` + - `IOrganizationResource.SubAccounts().Create(request)` — `POST /api/organizations/{organization_id}/sub_accounts` + ## [3.1.1] - 2026-03-30 ### Fixes & Maintenance diff --git a/Mailtrap.sln b/Mailtrap.sln index 79588b51..89218e89 100644 --- a/Mailtrap.sln +++ b/Mailtrap.sln @@ -110,6 +110,12 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Mailtrap.Example.Stats", "e EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Mailtrap.Example.EmailLogs", "examples\Mailtrap.Example.EmailLogs\Mailtrap.Example.EmailLogs.csproj", "{C4F8B2D3-5E6A-7F9B-0C1D-2E3F4A5B6C7D}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Mailtrap.Example.ApiTokens", "examples\Mailtrap.Example.ApiTokens\Mailtrap.Example.ApiTokens.csproj", "{D5E6F7A8-1234-5678-9ABC-DEF012345601}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Mailtrap.Example.Webhooks", "examples\Mailtrap.Example.Webhooks\Mailtrap.Example.Webhooks.csproj", "{D5E6F7A8-1234-5678-9ABC-DEF012345602}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Mailtrap.Example.SubAccount", "examples\Mailtrap.Example.SubAccount\Mailtrap.Example.SubAccount.csproj", "{D5E6F7A8-1234-5678-9ABC-DEF012345603}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -228,6 +234,18 @@ Global {C4F8B2D3-5E6A-7F9B-0C1D-2E3F4A5B6C7D}.Debug|Any CPU.Build.0 = Debug|Any CPU {C4F8B2D3-5E6A-7F9B-0C1D-2E3F4A5B6C7D}.Release|Any CPU.ActiveCfg = Release|Any CPU {C4F8B2D3-5E6A-7F9B-0C1D-2E3F4A5B6C7D}.Release|Any CPU.Build.0 = Release|Any CPU + {D5E6F7A8-1234-5678-9ABC-DEF012345601}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D5E6F7A8-1234-5678-9ABC-DEF012345601}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D5E6F7A8-1234-5678-9ABC-DEF012345601}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D5E6F7A8-1234-5678-9ABC-DEF012345601}.Release|Any CPU.Build.0 = Release|Any CPU + {D5E6F7A8-1234-5678-9ABC-DEF012345602}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D5E6F7A8-1234-5678-9ABC-DEF012345602}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D5E6F7A8-1234-5678-9ABC-DEF012345602}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D5E6F7A8-1234-5678-9ABC-DEF012345602}.Release|Any CPU.Build.0 = Release|Any CPU + {D5E6F7A8-1234-5678-9ABC-DEF012345603}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D5E6F7A8-1234-5678-9ABC-DEF012345603}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D5E6F7A8-1234-5678-9ABC-DEF012345603}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D5E6F7A8-1234-5678-9ABC-DEF012345603}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -261,6 +279,9 @@ Global {11894B20-F780-4FC9-8140-3601EBF54C10} = {09E18837-1DDE-4EAF-80EC-DA55557C81EB} {B3E7A1C2-4D5F-6E8A-9B0C-1D2E3F4A5B6C} = {09E18837-1DDE-4EAF-80EC-DA55557C81EB} {C4F8B2D3-5E6A-7F9B-0C1D-2E3F4A5B6C7D} = {09E18837-1DDE-4EAF-80EC-DA55557C81EB} + {D5E6F7A8-1234-5678-9ABC-DEF012345601} = {09E18837-1DDE-4EAF-80EC-DA55557C81EB} + {D5E6F7A8-1234-5678-9ABC-DEF012345602} = {09E18837-1DDE-4EAF-80EC-DA55557C81EB} + {D5E6F7A8-1234-5678-9ABC-DEF012345603} = {09E18837-1DDE-4EAF-80EC-DA55557C81EB} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {0FF614CC-FEBC-4C66-B3FC-FCB73EE511D7} diff --git a/README.md b/README.md index e7c90b9c..710e8c8e 100644 --- a/README.md +++ b/README.md @@ -276,6 +276,7 @@ private static SendEmailRequest TemplateBasedRequest() - Suppressions management – [`examples/Mailtrap.Example.Suppression`](examples/Mailtrap.Example.Suppression/) - Email logs (list with filters and pagination; get message details with events) – [`examples/Mailtrap.Example.EmailLogs`](examples/Mailtrap.Example.EmailLogs/) - Email sending statistics – [`examples/Mailtrap.Example.Stats`](examples/Mailtrap.Example.Stats/) +- Webhooks management – [`examples/Mailtrap.Example.Webhooks`](examples/Mailtrap.Example.Webhooks/) ### Email Sandbox (Testing) @@ -301,6 +302,8 @@ private static SendEmailRequest TemplateBasedRequest() - Account access management – [`examples/Mailtrap.Example.AccountAccess`](examples/Mailtrap.Example.AccountAccess/) - Permissions management – [`examples/Mailtrap.Example.Permissions`](examples/Mailtrap.Example.Permissions/) - Accounts management – [`examples/Mailtrap.Example.Account`](examples/Mailtrap.Example.Account/) +- Sub accounts management – [`examples/Mailtrap.Example.SubAccount`](examples/Mailtrap.Example.SubAccount/) +- API tokens management – [`examples/Mailtrap.Example.ApiTokens`](examples/Mailtrap.Example.ApiTokens/) - Billing information – [`examples/Mailtrap.Example.Billing`](examples/Mailtrap.Example.Billing/) - Comprehensive API Usage – [`examples/Mailtrap.Example.ApiUsage`](examples/Mailtrap.Example.ApiUsage/) diff --git a/examples/Mailtrap.Example.ApiTokens/Mailtrap.Example.ApiTokens.csproj b/examples/Mailtrap.Example.ApiTokens/Mailtrap.Example.ApiTokens.csproj new file mode 100644 index 00000000..b4b1d3ea --- /dev/null +++ b/examples/Mailtrap.Example.ApiTokens/Mailtrap.Example.ApiTokens.csproj @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/examples/Mailtrap.Example.ApiTokens/Program.cs b/examples/Mailtrap.Example.ApiTokens/Program.cs new file mode 100644 index 00000000..5fee8ac6 --- /dev/null +++ b/examples/Mailtrap.Example.ApiTokens/Program.cs @@ -0,0 +1,83 @@ +using Mailtrap; +using Mailtrap.Accounts; +using Mailtrap.ApiTokens; +using Mailtrap.ApiTokens.Models; +using Mailtrap.ApiTokens.Requests; +using Mailtrap.ApiTokens.Responses; +using Mailtrap.Core.Models; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + + +HostApplicationBuilder hostBuilder = Host.CreateApplicationBuilder(args); + +IConfigurationSection config = hostBuilder.Configuration.GetSection("Mailtrap"); + +hostBuilder.Services.AddMailtrapClient(config); + +using IHost host = hostBuilder.Build(); + +ILogger logger = host.Services.GetRequiredService>(); +IMailtrapClient mailtrapClient = host.Services.GetRequiredService(); + +try +{ + var accountId = 12345; + var inboxId = 67890; + IAccountResource accountResource = mailtrapClient.Account(accountId); + + // Get resource for API tokens collection + IApiTokenCollectionResource apiTokensResource = accountResource.ApiTokens(); + + // List all API tokens visible to the current API token + IList apiTokens = await apiTokensResource.GetAll(); + logger.LogInformation("Found {Count} API token(s).", apiTokens.Count); + + // Create a new API token scoped to a specific inbox with Viewer access + var createRequest = new CreateApiTokenRequest + { + Name = "Demo Viewer Token" + }; + createRequest.Resources.Add(new ApiTokenAccessRequest(ResourceType.Inbox, inboxId, AccessLevel.Viewer)); + + CreateApiTokenResponse createdToken = await apiTokensResource.Create(createRequest); + + // The full token value is only returned at creation time - store it securely + logger.LogInformation( + "Created API Token: Id={Id}, Name={Name}, Last4={Last4}", + createdToken.Id, + createdToken.Name, + createdToken.Last4Digits); + logger.LogInformation("Full token value (store securely, returned only once): {Token}", createdToken.Token); + + // Get resource for the specific API token + IApiTokenResource apiTokenResource = accountResource.ApiToken(createdToken.Id); + + // Get details of the API token + ApiToken tokenDetails = await apiTokenResource.GetDetails(); + logger.LogInformation("Token details: Id={Id}, Name={Name}, CreatedBy={CreatedBy}", + tokenDetails.Id, + tokenDetails.Name, + tokenDetails.CreatedBy); + + // Reset the API token - expires the current token and returns a new one with the same permissions + ApiTokenResetResponse resetResponse = await apiTokenResource.Reset(); + logger.LogInformation( + "Reset API Token: Id={Id}, NewLast4={Last4}", + resetResponse.Id, + resetResponse.Last4Digits); + logger.LogInformation("New token value (store securely, returned only once): {Token}", resetResponse.Token); + + // Delete the API token + // The API token resource becomes invalid after deletion and should not be used anymore + await apiTokenResource.Delete(); + logger.LogInformation("API Token Deleted."); +} +catch (Exception ex) +{ + logger.LogError(ex, "An error occurred during API call."); + Environment.ExitCode = 1; + return; +} diff --git a/examples/Mailtrap.Example.ApiTokens/Properties/launchSettings.json b/examples/Mailtrap.Example.ApiTokens/Properties/launchSettings.json new file mode 100644 index 00000000..b8daf491 --- /dev/null +++ b/examples/Mailtrap.Example.ApiTokens/Properties/launchSettings.json @@ -0,0 +1,10 @@ +{ + "profiles": { + "Project": { + "commandName": "Project", + "environmentVariables": { + "DOTNET_ENVIRONMENT": "Development" + } + } + } +} diff --git a/examples/Mailtrap.Example.ApiTokens/appsettings.json b/examples/Mailtrap.Example.ApiTokens/appsettings.json new file mode 100644 index 00000000..7cf4089d --- /dev/null +++ b/examples/Mailtrap.Example.ApiTokens/appsettings.json @@ -0,0 +1,17 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "System": "Warning", + "Microsoft": "Warning" + }, + "Debug": { + "LogLevel": { + "Default": "Debug" + } + } + }, + "Mailtrap": { + "ApiToken": "" + } +} diff --git a/examples/Mailtrap.Example.SubAccount/Mailtrap.Example.SubAccount.csproj b/examples/Mailtrap.Example.SubAccount/Mailtrap.Example.SubAccount.csproj new file mode 100644 index 00000000..6b512ec9 --- /dev/null +++ b/examples/Mailtrap.Example.SubAccount/Mailtrap.Example.SubAccount.csproj @@ -0,0 +1 @@ + diff --git a/examples/Mailtrap.Example.SubAccount/Program.cs b/examples/Mailtrap.Example.SubAccount/Program.cs new file mode 100644 index 00000000..1f3c533b --- /dev/null +++ b/examples/Mailtrap.Example.SubAccount/Program.cs @@ -0,0 +1,61 @@ +using Mailtrap; +using Mailtrap.Organizations; +using Mailtrap.Organizations.Models; +using Mailtrap.Organizations.Requests; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + + +HostApplicationBuilder hostBuilder = Host.CreateApplicationBuilder(args); + +IConfigurationSection config = hostBuilder.Configuration.GetSection("Mailtrap"); + +hostBuilder.Services.AddMailtrapClient(config); + +using IHost host = hostBuilder.Build(); + +ILogger logger = host.Services.GetRequiredService>(); + +// Sub accounts live under /api/organizations/{organization_id}/... so they are +// accessed via the organization-scoped client, not IMailtrapClient. +IMailtrapOrganizationClient organizationClient = host.Services.GetRequiredService(); + +try +{ + var organizationId = 12345; + IOrganizationResource organizationResource = organizationClient.Organization(organizationId); + + // Get resource for sub accounts collection + IOrganizationSubAccountCollectionResource subAccountsResource = organizationResource.SubAccounts(); + + // List sub accounts of the organization + IList subAccounts = await subAccountsResource.GetAll(); + logger.LogInformation("Found {Count} sub account(s).", subAccounts.Count); + + foreach (SubAccount subAccount in subAccounts) + { + logger.LogInformation("Sub Account: Id={Id}, Name={Name}", subAccount.Id, subAccount.Name); + } + + // Create a new sub account under the organization + var createRequest = new CreateSubAccountRequest + { + Account = new SubAccountAttributes + { + Name = "Demo Sub Account" + } + }; + SubAccount createdSubAccount = await subAccountsResource.Create(createRequest); + logger.LogInformation( + "Created Sub Account: Id={Id}, Name={Name}", + createdSubAccount.Id, + createdSubAccount.Name); +} +catch (Exception ex) +{ + logger.LogError(ex, "An error occurred during API call."); + Environment.ExitCode = 1; + return; +} diff --git a/examples/Mailtrap.Example.SubAccount/Properties/launchSettings.json b/examples/Mailtrap.Example.SubAccount/Properties/launchSettings.json new file mode 100644 index 00000000..b8daf491 --- /dev/null +++ b/examples/Mailtrap.Example.SubAccount/Properties/launchSettings.json @@ -0,0 +1,10 @@ +{ + "profiles": { + "Project": { + "commandName": "Project", + "environmentVariables": { + "DOTNET_ENVIRONMENT": "Development" + } + } + } +} diff --git a/examples/Mailtrap.Example.SubAccount/appsettings.json b/examples/Mailtrap.Example.SubAccount/appsettings.json new file mode 100644 index 00000000..7cf4089d --- /dev/null +++ b/examples/Mailtrap.Example.SubAccount/appsettings.json @@ -0,0 +1,17 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "System": "Warning", + "Microsoft": "Warning" + }, + "Debug": { + "LogLevel": { + "Default": "Debug" + } + } + }, + "Mailtrap": { + "ApiToken": "" + } +} diff --git a/examples/Mailtrap.Example.Webhooks/Mailtrap.Example.Webhooks.csproj b/examples/Mailtrap.Example.Webhooks/Mailtrap.Example.Webhooks.csproj new file mode 100644 index 00000000..6b512ec9 --- /dev/null +++ b/examples/Mailtrap.Example.Webhooks/Mailtrap.Example.Webhooks.csproj @@ -0,0 +1 @@ + diff --git a/examples/Mailtrap.Example.Webhooks/Program.cs b/examples/Mailtrap.Example.Webhooks/Program.cs new file mode 100644 index 00000000..ebc98eed --- /dev/null +++ b/examples/Mailtrap.Example.Webhooks/Program.cs @@ -0,0 +1,92 @@ +using Mailtrap; +using Mailtrap.Accounts; +using Mailtrap.Webhooks; +using Mailtrap.Core.Models; +using Mailtrap.Webhooks.Models; +using Mailtrap.Webhooks.Requests; +using Mailtrap.Webhooks.Responses; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + + +HostApplicationBuilder hostBuilder = Host.CreateApplicationBuilder(args); + +IConfigurationSection config = hostBuilder.Configuration.GetSection("Mailtrap"); + +hostBuilder.Services.AddMailtrapClient(config); + +using IHost host = hostBuilder.Build(); + +ILogger logger = host.Services.GetRequiredService>(); +IMailtrapClient mailtrapClient = host.Services.GetRequiredService(); + +try +{ + var accountId = 12345; + IAccountResource accountResource = mailtrapClient.Account(accountId); + + // Get resource for webhooks collection + IWebhookCollectionResource webhooksResource = accountResource.Webhooks(); + + // List all webhooks for the account + IList webhooks = await webhooksResource.GetAll(); + logger.LogInformation("Found {Count} webhook(s).", webhooks.Count); + + // Create a new "email_sending" webhook subscribed to delivery + bounce events on the transactional stream + var createRequest = new CreateWebhookRequest + { + Url = new Uri("https://example.com/webhooks/mailtrap"), + WebhookType = WebhookType.EmailSending, + Active = true, + PayloadFormat = WebhookPayloadFormat.Json, + SendingStream = SendingStream.Transactional + }; + createRequest.EventTypes.Add(WebhookEventType.Delivery); + createRequest.EventTypes.Add(WebhookEventType.Bounce); + + CreateWebhookResponse createdWebhook = await webhooksResource.Create(createRequest); + + // The signing secret is only returned at creation time - store it securely + logger.LogInformation( + "Created Webhook: Id={Id}, Url={Url}, Type={Type}", + createdWebhook.Id, + createdWebhook.Url, + createdWebhook.WebhookType); + logger.LogInformation("Signing secret (store securely, returned only once): {Secret}", createdWebhook.SigningSecret); + + // Get resource for the specific webhook + IWebhookResource webhookResource = accountResource.Webhook(createdWebhook.Id); + + // Get details of the webhook + Webhook webhookDetails = await webhookResource.GetDetails(); + logger.LogInformation("Webhook details: Id={Id}, Url={Url}, Active={Active}", + webhookDetails.Id, + webhookDetails.Url, + webhookDetails.Active); + + // Update the webhook - swap the event types and disable it + var updateRequest = new UpdateWebhookRequest + { + Active = false, + EventTypes = [WebhookEventType.Delivery, WebhookEventType.Open, WebhookEventType.Click] + }; + Webhook updatedWebhook = await webhookResource.Update(updateRequest); + logger.LogInformation( + "Updated Webhook: Id={Id}, Active={Active}, EventTypes={EventTypes}", + updatedWebhook.Id, + updatedWebhook.Active, + string.Join(",", updatedWebhook.EventTypes)); + + // Delete the webhook + // The webhook resource becomes invalid after deletion and should not be used anymore + Webhook deletedWebhook = await webhookResource.Delete(); + logger.LogInformation("Webhook Deleted: Id={Id}", deletedWebhook.Id); +} +catch (Exception ex) +{ + logger.LogError(ex, "An error occurred during API call."); + Environment.ExitCode = 1; + return; +} diff --git a/examples/Mailtrap.Example.Webhooks/Properties/launchSettings.json b/examples/Mailtrap.Example.Webhooks/Properties/launchSettings.json new file mode 100644 index 00000000..b8daf491 --- /dev/null +++ b/examples/Mailtrap.Example.Webhooks/Properties/launchSettings.json @@ -0,0 +1,10 @@ +{ + "profiles": { + "Project": { + "commandName": "Project", + "environmentVariables": { + "DOTNET_ENVIRONMENT": "Development" + } + } + } +} diff --git a/examples/Mailtrap.Example.Webhooks/appsettings.json b/examples/Mailtrap.Example.Webhooks/appsettings.json new file mode 100644 index 00000000..7cf4089d --- /dev/null +++ b/examples/Mailtrap.Example.Webhooks/appsettings.json @@ -0,0 +1,17 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "System": "Warning", + "Microsoft": "Warning" + }, + "Debug": { + "LogLevel": { + "Default": "Debug" + } + } + }, + "Mailtrap": { + "ApiToken": "" + } +} diff --git a/src/Mailtrap.Abstractions/Accounts/IAccountResource.cs b/src/Mailtrap.Abstractions/Accounts/IAccountResource.cs index ade436a3..6ddfad51 100644 --- a/src/Mailtrap.Abstractions/Accounts/IAccountResource.cs +++ b/src/Mailtrap.Abstractions/Accounts/IAccountResource.cs @@ -246,4 +246,57 @@ public interface IAccountResource : IRestResource /// When is null or empty. /// public IEmailLogResource EmailLog(string sendingMessageId); + + /// + /// Gets API token collection resource for the account, represented by this resource instance. + /// + /// + /// + /// API token collection resource for the account, represented by this resource instance. + /// + public IApiTokenCollectionResource ApiTokens(); + + /// + /// Gets resource for specific API token, identified by . + /// + /// + /// + /// ID of API token to get resource for. + /// + /// + /// + /// Resource for the API token with specified ID. + /// + /// + /// + /// When is less than or equal to zero. + /// + public IApiTokenResource ApiToken(long apiTokenId); + + + /// + /// Gets webhook collection resource for the account, represented by this resource instance. + /// + /// + /// + /// Webhook collection resource for the account, represented by this resource instance. + /// + public IWebhookCollectionResource Webhooks(); + + /// + /// Gets resource for specific webhook, identified by . + /// + /// + /// + /// ID of webhook to get resource for. + /// + /// + /// + /// Resource for the webhook with specified ID. + /// + /// + /// + /// When is less than or equal to zero. + /// + public IWebhookResource Webhook(long webhookId); } diff --git a/src/Mailtrap.Abstractions/ApiTokens/IApiTokenCollectionResource.cs b/src/Mailtrap.Abstractions/ApiTokens/IApiTokenCollectionResource.cs new file mode 100644 index 00000000..19e759c7 --- /dev/null +++ b/src/Mailtrap.Abstractions/ApiTokens/IApiTokenCollectionResource.cs @@ -0,0 +1,42 @@ +namespace Mailtrap.ApiTokens; + + +/// +/// Represents API token collection resource. +/// +public interface IApiTokenCollectionResource : IRestResource +{ + /// + /// List all API tokens visible to the current API token. + /// + /// + /// + /// Token to control operation cancellation. + /// + /// + /// + /// Collection of API token details. + /// + public Task> GetAll(CancellationToken cancellationToken = default); + + /// + /// Create a new API token. + /// + /// + /// + /// API token creation request. + /// + /// + /// + /// Token to control operation cancellation. + /// + /// + /// + /// Created API token details, including the full token value. + /// + /// + /// + /// The full token value is only returned once at creation — store it securely. + /// + public Task Create(CreateApiTokenRequest request, CancellationToken cancellationToken = default); +} diff --git a/src/Mailtrap.Abstractions/ApiTokens/IApiTokenResource.cs b/src/Mailtrap.Abstractions/ApiTokens/IApiTokenResource.cs new file mode 100644 index 00000000..328e0b7e --- /dev/null +++ b/src/Mailtrap.Abstractions/ApiTokens/IApiTokenResource.cs @@ -0,0 +1,49 @@ +namespace Mailtrap.ApiTokens; + + +/// +/// Represents a single API token resource. +/// +public interface IApiTokenResource : IRestResource +{ + /// + /// Get details of the API token represented by this resource. + /// + /// + /// + /// Token to control operation cancellation. + /// + /// + /// + /// API token details. + /// + public Task GetDetails(CancellationToken cancellationToken = default); + + /// + /// Permanently delete the API token represented by this resource. + /// + /// + /// + /// Token to control operation cancellation. + /// + public Task Delete(CancellationToken cancellationToken = default); + + /// + /// Reset the API token represented by this resource. + /// + /// + /// + /// Token to control operation cancellation. + /// + /// + /// + /// New API token details, including the full token value. + /// + /// + /// + /// Expires the requested token and creates a new token with the same permissions. + /// The old token stops working after a short grace period. The response includes + /// the new token value — store it securely; it is only returned once. + /// + public Task Reset(CancellationToken cancellationToken = default); +} diff --git a/src/Mailtrap.Abstractions/ApiTokens/Models/ApiToken.cs b/src/Mailtrap.Abstractions/ApiTokens/Models/ApiToken.cs new file mode 100644 index 00000000..fff7c952 --- /dev/null +++ b/src/Mailtrap.Abstractions/ApiTokens/Models/ApiToken.cs @@ -0,0 +1,76 @@ +namespace Mailtrap.ApiTokens.Models; + + +/// +/// Represents API token details. +/// +public sealed record ApiToken +{ + /// + /// Gets the API token identifier. + /// + /// + /// + /// API token identifier. + /// + [JsonPropertyName("id")] + [JsonPropertyOrder(1)] + [JsonRequired] + public long Id { get; set; } + + /// + /// Gets the API token display name. + /// + /// + /// + /// API token display name. + /// + [JsonPropertyName("name")] + [JsonPropertyOrder(2)] + public string Name { get; set; } = string.Empty; + + /// + /// Gets the last 4 characters of the token. + /// + /// + /// + /// Last 4 characters of the token. The full token value is only returned on create or reset. + /// + [JsonPropertyName("last_4_digits")] + [JsonPropertyOrder(3)] + public string Last4Digits { get; set; } = string.Empty; + + /// + /// Gets the name of the user or token that created this API token. + /// + /// + /// + /// Creator name or if not available. + /// + [JsonPropertyName("created_by")] + [JsonPropertyOrder(4)] + public string? CreatedBy { get; set; } + + /// + /// Gets the date and time when the API token expires. + /// + /// + /// + /// Expiration date and time, or if the token does not expire. + /// + [JsonPropertyName("expires_at")] + [JsonPropertyOrder(5)] + public DateTimeOffset? ExpiresAt { get; set; } + + /// + /// Gets the resource accesses granted to this API token. + /// + /// + /// + /// Collection of resource accesses. + /// + [JsonPropertyName("resources")] + [JsonPropertyOrder(6)] + [JsonObjectCreationHandling(JsonObjectCreationHandling.Populate)] + public IList Resources { get; } = []; +} diff --git a/src/Mailtrap.Abstractions/ApiTokens/Models/ApiTokenAccess.cs b/src/Mailtrap.Abstractions/ApiTokens/Models/ApiTokenAccess.cs new file mode 100644 index 00000000..8c014885 --- /dev/null +++ b/src/Mailtrap.Abstractions/ApiTokens/Models/ApiTokenAccess.cs @@ -0,0 +1,42 @@ +namespace Mailtrap.ApiTokens.Models; + + +/// +/// Represents an access entry granted to an API token for a specific resource. +/// +public sealed record ApiTokenAccess +{ + /// + /// Gets the resource type. + /// + /// + /// + /// Resource type. + /// + [JsonPropertyName("resource_type")] + [JsonPropertyOrder(1)] + public ResourceType Type { get; set; } = ResourceType.Unknown; + + /// + /// Gets the resource identifier. + /// + /// + /// + /// Resource identifier. + /// + [JsonPropertyName("resource_id")] + [JsonPropertyOrder(2)] + [JsonRequired] + public long Id { get; set; } + + /// + /// Gets the resource access level. + /// + /// + /// + /// Access level for resource. + /// + [JsonPropertyName("access_level")] + [JsonPropertyOrder(3)] + public AccessLevel AccessLevel { get; set; } = AccessLevel.Indeterminate; +} diff --git a/src/Mailtrap.Abstractions/ApiTokens/Requests/ApiTokenAccessRequest.cs b/src/Mailtrap.Abstractions/ApiTokens/Requests/ApiTokenAccessRequest.cs new file mode 100644 index 00000000..db9025de --- /dev/null +++ b/src/Mailtrap.Abstractions/ApiTokens/Requests/ApiTokenAccessRequest.cs @@ -0,0 +1,96 @@ +namespace Mailtrap.ApiTokens.Requests; + + +/// +/// Represents an access entry to grant to an API token for a specific resource. +/// +public sealed record ApiTokenAccessRequest : IValidatable +{ + /// + /// Gets the resource type. + /// + /// + /// + /// Resource type. + /// + [JsonPropertyName("resource_type")] + [JsonPropertyOrder(1)] + public ResourceType ResourceType { get; } + + /// + /// Gets the resource identifier. + /// + /// + /// + /// Resource identifier. + /// + [JsonPropertyName("resource_id")] + [JsonPropertyOrder(2)] + public long ResourceId { get; } + + /// + /// Gets the resource access level. + /// + /// + /// + /// Access level for resource. Allowed values: or . + /// + [JsonPropertyName("access_level")] + [JsonPropertyOrder(3)] + public AccessLevel AccessLevel { get; } + + + /// + /// Primary instance constructor. + /// + /// + /// + /// Type of the resource to grant access to. + /// + /// + /// + /// ID of the resource to grant access to. + /// + /// + /// + /// Access level for the resource. Allowed values: or . + /// + /// + /// + /// When is . + /// + /// + /// + /// When is less than or equal to zero, + /// or is not or . + /// + public ApiTokenAccessRequest( + ResourceType resourceType, + long resourceId, + AccessLevel accessLevel) + { + Ensure.NotNull(resourceType, nameof(resourceType)); + Ensure.GreaterThanZero(resourceId, nameof(resourceId)); + + if (accessLevel is not AccessLevel.Viewer and not AccessLevel.Admin) + { + throw new ArgumentOutOfRangeException( + nameof(accessLevel), + accessLevel, + "Allowed values are Viewer or Admin"); + } + + ResourceType = resourceType; + ResourceId = resourceId; + AccessLevel = accessLevel; + } + + + /// + public ValidationResult Validate() + { + return ApiTokenAccessRequestValidator.Instance + .Validate(this) + .ToMailtrapValidationResult(); + } +} diff --git a/src/Mailtrap.Abstractions/ApiTokens/Requests/CreateApiTokenRequest.cs b/src/Mailtrap.Abstractions/ApiTokens/Requests/CreateApiTokenRequest.cs new file mode 100644 index 00000000..2f332166 --- /dev/null +++ b/src/Mailtrap.Abstractions/ApiTokens/Requests/CreateApiTokenRequest.cs @@ -0,0 +1,41 @@ +namespace Mailtrap.ApiTokens.Requests; + + +/// +/// Request to create a new API token. +/// +public sealed record CreateApiTokenRequest : IValidatable +{ + /// + /// Gets or sets the API token display name. + /// + /// + /// + /// API token display name. + /// + [JsonPropertyName("name")] + [JsonPropertyOrder(1)] + [JsonRequired] + public string Name { get; set; } = string.Empty; + + /// + /// Gets the resource accesses to grant to the API token. + /// + /// + /// + /// Collection of resource accesses. + /// + [JsonPropertyName("resources")] + [JsonPropertyOrder(2)] + [JsonObjectCreationHandling(JsonObjectCreationHandling.Populate)] + public IList Resources { get; } = []; + + + /// + public ValidationResult Validate() + { + return CreateApiTokenRequestValidator.Instance + .Validate(this) + .ToMailtrapValidationResult(); + } +} diff --git a/src/Mailtrap.Abstractions/ApiTokens/Responses/ApiTokenResetResponse.cs b/src/Mailtrap.Abstractions/ApiTokens/Responses/ApiTokenResetResponse.cs new file mode 100644 index 00000000..c36c7df1 --- /dev/null +++ b/src/Mailtrap.Abstractions/ApiTokens/Responses/ApiTokenResetResponse.cs @@ -0,0 +1,60 @@ +namespace Mailtrap.ApiTokens.Responses; + + +/// +/// Response returned when an API token is reset. Includes the new full token value, +/// which is only returned once at reset time. +/// +public sealed record ApiTokenResetResponse +{ + /// + /// Gets the API token identifier. + /// + [JsonPropertyName("id")] + [JsonPropertyOrder(1)] + [JsonRequired] + public long Id { get; set; } + + /// + /// Gets the API token display name. + /// + [JsonPropertyName("name")] + [JsonPropertyOrder(2)] + public string Name { get; set; } = string.Empty; + + /// + /// Gets the last 4 characters of the new token. + /// + [JsonPropertyName("last_4_digits")] + [JsonPropertyOrder(3)] + public string Last4Digits { get; set; } = string.Empty; + + /// + /// Gets the name of the user or token that created this API token. + /// + [JsonPropertyName("created_by")] + [JsonPropertyOrder(4)] + public string? CreatedBy { get; set; } + + /// + /// Gets the date and time when the API token expires, or if it does not expire. + /// + [JsonPropertyName("expires_at")] + [JsonPropertyOrder(5)] + public DateTimeOffset? ExpiresAt { get; set; } + + /// + /// Gets the resource accesses granted to this API token. + /// + [JsonPropertyName("resources")] + [JsonPropertyOrder(6)] + [JsonObjectCreationHandling(JsonObjectCreationHandling.Populate)] + public IList Resources { get; } = []; + + /// + /// Gets the new full token value. Only returned at reset time — store it securely. + /// + [JsonPropertyName("token")] + [JsonPropertyOrder(7)] + public string Token { get; set; } = string.Empty; +} diff --git a/src/Mailtrap.Abstractions/ApiTokens/Responses/CreateApiTokenResponse.cs b/src/Mailtrap.Abstractions/ApiTokens/Responses/CreateApiTokenResponse.cs new file mode 100644 index 00000000..9a01e250 --- /dev/null +++ b/src/Mailtrap.Abstractions/ApiTokens/Responses/CreateApiTokenResponse.cs @@ -0,0 +1,60 @@ +namespace Mailtrap.ApiTokens.Responses; + + +/// +/// Response returned when an API token is created. Includes the full token value, +/// which is only returned once at creation time. +/// +public sealed record CreateApiTokenResponse +{ + /// + /// Gets the API token identifier. + /// + [JsonPropertyName("id")] + [JsonPropertyOrder(1)] + [JsonRequired] + public long Id { get; set; } + + /// + /// Gets the API token display name. + /// + [JsonPropertyName("name")] + [JsonPropertyOrder(2)] + public string Name { get; set; } = string.Empty; + + /// + /// Gets the last 4 characters of the token. + /// + [JsonPropertyName("last_4_digits")] + [JsonPropertyOrder(3)] + public string Last4Digits { get; set; } = string.Empty; + + /// + /// Gets the name of the user or token that created this API token. + /// + [JsonPropertyName("created_by")] + [JsonPropertyOrder(4)] + public string? CreatedBy { get; set; } + + /// + /// Gets the date and time when the API token expires, or if it does not expire. + /// + [JsonPropertyName("expires_at")] + [JsonPropertyOrder(5)] + public DateTimeOffset? ExpiresAt { get; set; } + + /// + /// Gets the resource accesses granted to this API token. + /// + [JsonPropertyName("resources")] + [JsonPropertyOrder(6)] + [JsonObjectCreationHandling(JsonObjectCreationHandling.Populate)] + public IList Resources { get; } = []; + + /// + /// Gets the full token value. Only returned at creation time — store it securely. + /// + [JsonPropertyName("token")] + [JsonPropertyOrder(7)] + public string Token { get; set; } = string.Empty; +} diff --git a/src/Mailtrap.Abstractions/ApiTokens/Validators/ApiTokenAccessRequestValidator.cs b/src/Mailtrap.Abstractions/ApiTokens/Validators/ApiTokenAccessRequestValidator.cs new file mode 100644 index 00000000..b3e4ce7a --- /dev/null +++ b/src/Mailtrap.Abstractions/ApiTokens/Validators/ApiTokenAccessRequestValidator.cs @@ -0,0 +1,22 @@ +namespace Mailtrap.ApiTokens.Validators; + + +internal sealed class ApiTokenAccessRequestValidator : AbstractValidator +{ + public static ApiTokenAccessRequestValidator Instance { get; } = new(); + + public ApiTokenAccessRequestValidator() + { + RuleFor(r => r.ResourceType) + .NotEmpty() + .NotEqual(ResourceType.None); + + RuleFor(r => r.ResourceId) + .GreaterThan(0); + + RuleFor(r => r.AccessLevel) + .NotEmpty() + .IsInEnum() + .Must(l => l is AccessLevel.Admin or AccessLevel.Viewer); + } +} diff --git a/src/Mailtrap.Abstractions/ApiTokens/Validators/CreateApiTokenRequestValidator.cs b/src/Mailtrap.Abstractions/ApiTokens/Validators/CreateApiTokenRequestValidator.cs new file mode 100644 index 00000000..477fb80c --- /dev/null +++ b/src/Mailtrap.Abstractions/ApiTokens/Validators/CreateApiTokenRequestValidator.cs @@ -0,0 +1,25 @@ +namespace Mailtrap.ApiTokens.Validators; + + +/// +/// Validator for requests.
+/// Ensures name is not empty and that every nested resource access entry is valid. +///
+public sealed class CreateApiTokenRequestValidator : AbstractValidator +{ + /// + /// Static validator instance for reuse. + /// + public static CreateApiTokenRequestValidator Instance { get; } = new(); + + /// + /// Primary constructor. + /// + public CreateApiTokenRequestValidator() + { + RuleFor(r => r.Name).NotEmpty().MaximumLength(255); + + RuleForEach(r => r.Resources) + .SetValidator(ApiTokenAccessRequestValidator.Instance); + } +} diff --git a/src/Mailtrap.Abstractions/EmailLogs/Models/SendingStream.cs b/src/Mailtrap.Abstractions/Core/Models/SendingStream.cs similarity index 79% rename from src/Mailtrap.Abstractions/EmailLogs/Models/SendingStream.cs rename to src/Mailtrap.Abstractions/Core/Models/SendingStream.cs index abd787c0..006ab65f 100644 --- a/src/Mailtrap.Abstractions/EmailLogs/Models/SendingStream.cs +++ b/src/Mailtrap.Abstractions/Core/Models/SendingStream.cs @@ -1,8 +1,8 @@ -namespace Mailtrap.EmailLogs.Models; +namespace Mailtrap.Core.Models; /// -/// Represents the sending stream for an email log message. +/// Represents an email sending stream. /// public sealed record SendingStream : StringEnum { diff --git a/src/Mailtrap.Abstractions/GlobalSuppressions.cs b/src/Mailtrap.Abstractions/GlobalSuppressions.cs index 1f842691..3677d735 100644 --- a/src/Mailtrap.Abstractions/GlobalSuppressions.cs +++ b/src/Mailtrap.Abstractions/GlobalSuppressions.cs @@ -15,10 +15,12 @@ [assembly: SuppressMessage("Usage", "CA2227:Collection properties should be read only", Justification = "DTO", Scope = "type", Target = "~T:Mailtrap.Emails.Requests.EmailRequest")] [assembly: SuppressMessage("Usage", "CA2227:Collection properties should be read only", Justification = "DTO", Scope = "type", Target = "~T:Mailtrap.Emails.Requests.BatchEmailRequest")] [assembly: SuppressMessage("Naming", "CA1711:Identifiers should not have incorrect suffix", Justification = "Suppression Sending Stream type should be named according to its value", Scope = "type", Target = "~T:Mailtrap.Suppressions.Models.SuppressionSendingStream")] -[assembly: SuppressMessage("Naming", "CA1711:Identifiers should not have incorrect suffix", Justification = "Email log sending stream type matches API value", Scope = "type", Target = "~T:Mailtrap.EmailLogs.Models.SendingStream")] +[assembly: SuppressMessage("Naming", "CA1711:Identifiers should not have incorrect suffix", Justification = "Sending stream type matches API value", Scope = "type", Target = "~T:Mailtrap.Core.Models.SendingStream")] [assembly: SuppressMessage("Usage", "CA2227:Collection properties should be read only", Justification = "DTO; setter required for JSON deserialization on netstandard2.0/pre-.NET 8", Scope = "member", Target = "~P:Mailtrap.EmailLogs.Models.EmailLogsListResponse.Messages")] [assembly: SuppressMessage("Usage", "CA2227:Collection properties should be read only", Justification = "DTO; setter required for JSON deserialization when API returns null", Scope = "member", Target = "~P:Mailtrap.EmailLogs.Models.EmailLogMessage.CustomVariables")] [assembly: SuppressMessage("Usage", "CA2227:Collection properties should be read only", Justification = "DTO; setter required for JSON deserialization when API returns null", Scope = "member", Target = "~P:Mailtrap.EmailLogs.Models.EmailLogMessage.TemplateVariables")] [assembly: SuppressMessage("Usage", "CA2227:Collection properties should be read only", Justification = "DTO; setter required for JSON deserialization when API returns null", Scope = "member", Target = "~P:Mailtrap.EmailLogs.Models.EmailLogMessage.Events")] [assembly: SuppressMessage("Design", "CA1056:URI-like properties should not be strings", Justification = "API returns string; signed/temporary URL", Scope = "member", Target = "~P:Mailtrap.EmailLogs.Models.EmailLogMessage.RawMessageUrl")] [assembly: SuppressMessage("Design", "CA1056:URI-like properties should not be strings", Justification = "API returns string; avoid Uri parsing for signed URLs", Scope = "member", Target = "~P:Mailtrap.EmailLogs.Models.EventDetailsClick.ClickUrl")] + +[assembly: SuppressMessage("Usage", "CA2227:Collection properties should be read only", Justification = "DTO; nullable setter required to distinguish 'unchanged' from 'set to empty' on PATCH", Scope = "member", Target = "~P:Mailtrap.Webhooks.Requests.UpdateWebhookRequest.EventTypes")] diff --git a/src/Mailtrap.Abstractions/GlobalUsings.cs b/src/Mailtrap.Abstractions/GlobalUsings.cs index 7899f17f..ed66356e 100644 --- a/src/Mailtrap.Abstractions/GlobalUsings.cs +++ b/src/Mailtrap.Abstractions/GlobalUsings.cs @@ -11,6 +11,11 @@ global using Mailtrap.AccountAccesses.Responses; global using Mailtrap.Accounts; global using Mailtrap.Accounts.Models; +global using Mailtrap.ApiTokens; +global using Mailtrap.ApiTokens.Models; +global using Mailtrap.ApiTokens.Requests; +global using Mailtrap.ApiTokens.Responses; +global using Mailtrap.ApiTokens.Validators; global using Mailtrap.Attachments; global using Mailtrap.Attachments.Models; global using Mailtrap.Billing; @@ -61,6 +66,10 @@ global using Mailtrap.Inboxes; global using Mailtrap.Inboxes.Models; global using Mailtrap.Inboxes.Requests; +global using Mailtrap.Organizations; +global using Mailtrap.Organizations.Models; +global using Mailtrap.Organizations.Requests; +global using Mailtrap.Organizations.Validators; global using Mailtrap.Permissions; global using Mailtrap.Permissions.Models; global using Mailtrap.Projects; @@ -79,4 +88,9 @@ global using Mailtrap.TestingMessages.Models; global using Mailtrap.TestingMessages.Requests; global using Mailtrap.TestingMessages.Responses; +global using Mailtrap.Webhooks; +global using Mailtrap.Webhooks.Models; +global using Mailtrap.Webhooks.Requests; +global using Mailtrap.Webhooks.Responses; +global using Mailtrap.Webhooks.Validators; diff --git a/src/Mailtrap.Abstractions/IMailtrapClientFactory.cs b/src/Mailtrap.Abstractions/IMailtrapClientFactory.cs index 33cffd90..946d3cc0 100644 --- a/src/Mailtrap.Abstractions/IMailtrapClientFactory.cs +++ b/src/Mailtrap.Abstractions/IMailtrapClientFactory.cs @@ -27,4 +27,17 @@ public interface IMailtrapClientFactory : IDisposable /// Each call to this method is guaranteed to return a new instance of . /// public IMailtrapClient CreateClient(); + + /// + /// Creates new instance of for organization-scoped operations. + /// + /// + /// + /// New instance. + /// + /// + /// + /// When factory was disposed. + /// + public IMailtrapOrganizationClient CreateOrganizationClient(); } diff --git a/src/Mailtrap.Abstractions/IMailtrapOrganizationClient.cs b/src/Mailtrap.Abstractions/IMailtrapOrganizationClient.cs new file mode 100644 index 00000000..4ecf9c3b --- /dev/null +++ b/src/Mailtrap.Abstractions/IMailtrapOrganizationClient.cs @@ -0,0 +1,31 @@ +namespace Mailtrap; + + +/// +/// Mailtrap organization-scoped API client. +/// +/// +/// +/// Exposes organization-level operations such as managing sub accounts. +/// Organization-scoped endpoints live under /api/organizations/{organization_id}/... +/// and require token permissions for the targeted organization. +/// +public interface IMailtrapOrganizationClient : IRestResource +{ + /// + /// Gets resource for specific organization, identified by . + /// + /// + /// + /// ID of organization to get resource for. + /// + /// + /// + /// Resource for the organization with specified ID. + /// + /// + /// + /// When is less than or equal to zero. + /// + public IOrganizationResource Organization(long organizationId); +} diff --git a/src/Mailtrap.Abstractions/Organizations/IOrganizationResource.cs b/src/Mailtrap.Abstractions/Organizations/IOrganizationResource.cs new file mode 100644 index 00000000..c2d3451e --- /dev/null +++ b/src/Mailtrap.Abstractions/Organizations/IOrganizationResource.cs @@ -0,0 +1,17 @@ +namespace Mailtrap.Organizations; + + +/// +/// Represents organization resource. +/// +public interface IOrganizationResource : IRestResource +{ + /// + /// Gets sub account collection resource for the organization, represented by this resource instance. + /// + /// + /// + /// Sub account collection resource for the organization, represented by this resource instance. + /// + public IOrganizationSubAccountCollectionResource SubAccounts(); +} diff --git a/src/Mailtrap.Abstractions/Organizations/IOrganizationSubAccountCollectionResource.cs b/src/Mailtrap.Abstractions/Organizations/IOrganizationSubAccountCollectionResource.cs new file mode 100644 index 00000000..053d4454 --- /dev/null +++ b/src/Mailtrap.Abstractions/Organizations/IOrganizationSubAccountCollectionResource.cs @@ -0,0 +1,38 @@ +namespace Mailtrap.Organizations; + + +/// +/// Represents organization sub account collection resource. +/// +public interface IOrganizationSubAccountCollectionResource : IRestResource +{ + /// + /// List sub accounts of the organization. + /// + /// + /// + /// Token to control operation cancellation. + /// + /// + /// + /// Collection of sub accounts. + /// + public Task> GetAll(CancellationToken cancellationToken = default); + + /// + /// Create a new sub account under the organization. + /// + /// + /// + /// Sub account creation request. + /// + /// + /// + /// Token to control operation cancellation. + /// + /// + /// + /// Created sub account details. + /// + public Task Create(CreateSubAccountRequest request, CancellationToken cancellationToken = default); +} diff --git a/src/Mailtrap.Abstractions/Organizations/Models/SubAccount.cs b/src/Mailtrap.Abstractions/Organizations/Models/SubAccount.cs new file mode 100644 index 00000000..c38b0668 --- /dev/null +++ b/src/Mailtrap.Abstractions/Organizations/Models/SubAccount.cs @@ -0,0 +1,23 @@ +namespace Mailtrap.Organizations.Models; + + +/// +/// Represents a sub account within an organization. +/// +public sealed record SubAccount +{ + /// + /// Gets the sub account identifier. + /// + [JsonPropertyName("id")] + [JsonPropertyOrder(1)] + [JsonRequired] + public long Id { get; set; } + + /// + /// Gets the sub account name. + /// + [JsonPropertyName("name")] + [JsonPropertyOrder(2)] + public string Name { get; set; } = string.Empty; +} diff --git a/src/Mailtrap.Abstractions/Organizations/Models/SubAccountAttributes.cs b/src/Mailtrap.Abstractions/Organizations/Models/SubAccountAttributes.cs new file mode 100644 index 00000000..cb7ddc63 --- /dev/null +++ b/src/Mailtrap.Abstractions/Organizations/Models/SubAccountAttributes.cs @@ -0,0 +1,15 @@ +namespace Mailtrap.Organizations.Models; + + +/// +/// Attributes used to create a sub account. +/// +public sealed record SubAccountAttributes +{ + /// + /// Gets or sets the sub account name. + /// + [JsonPropertyName("name")] + [JsonPropertyOrder(1)] + public string Name { get; set; } = string.Empty; +} diff --git a/src/Mailtrap.Abstractions/Organizations/Requests/CreateSubAccountRequest.cs b/src/Mailtrap.Abstractions/Organizations/Requests/CreateSubAccountRequest.cs new file mode 100644 index 00000000..4d67cd3c --- /dev/null +++ b/src/Mailtrap.Abstractions/Organizations/Requests/CreateSubAccountRequest.cs @@ -0,0 +1,26 @@ +namespace Mailtrap.Organizations.Requests; + + +/// +/// Request to create a new sub account under an organization. Wraps the attributes +/// in an account envelope, matching the API contract. +/// +public sealed record CreateSubAccountRequest : IValidatable +{ + /// + /// Gets or sets the sub account attributes. + /// + [JsonPropertyName("account")] + [JsonPropertyOrder(1)] + [JsonRequired] + public SubAccountAttributes Account { get; set; } = new(); + + + /// + public ValidationResult Validate() + { + return CreateSubAccountRequestValidator.Instance + .Validate(this) + .ToMailtrapValidationResult(); + } +} diff --git a/src/Mailtrap.Abstractions/Organizations/Validators/CreateSubAccountRequestValidator.cs b/src/Mailtrap.Abstractions/Organizations/Validators/CreateSubAccountRequestValidator.cs new file mode 100644 index 00000000..d37fa608 --- /dev/null +++ b/src/Mailtrap.Abstractions/Organizations/Validators/CreateSubAccountRequestValidator.cs @@ -0,0 +1,22 @@ +namespace Mailtrap.Organizations.Validators; + + +/// +/// Validator for . +/// +public sealed class CreateSubAccountRequestValidator : AbstractValidator +{ + /// + /// Static validator instance for reuse. + /// + public static CreateSubAccountRequestValidator Instance { get; } = new(); + + /// + /// Primary constructor. + /// + public CreateSubAccountRequestValidator() + { + RuleFor(r => r.Account).NotNull(); + RuleFor(r => r.Account.Name).NotEmpty().MaximumLength(255); + } +} diff --git a/src/Mailtrap.Abstractions/Webhooks/IWebhookCollectionResource.cs b/src/Mailtrap.Abstractions/Webhooks/IWebhookCollectionResource.cs new file mode 100644 index 00000000..aa55c007 --- /dev/null +++ b/src/Mailtrap.Abstractions/Webhooks/IWebhookCollectionResource.cs @@ -0,0 +1,42 @@ +namespace Mailtrap.Webhooks; + + +/// +/// Represents webhook collection resource. +/// +public interface IWebhookCollectionResource : IRestResource +{ + /// + /// Returns all webhooks for the account. + /// + /// + /// + /// Token to control operation cancellation. + /// + /// + /// + /// Collection of webhook details. + /// + public Task> GetAll(CancellationToken cancellationToken = default); + + /// + /// Creates a new webhook with details specified by . + /// + /// + /// + /// Request containing webhook details for creation. + /// + /// + /// + /// + /// + /// + /// + /// Created webhook details, including the . + /// + /// + /// + /// The signing secret is only returned at creation time - store it securely. + /// + public Task Create(CreateWebhookRequest request, CancellationToken cancellationToken = default); +} \ No newline at end of file diff --git a/src/Mailtrap.Abstractions/Webhooks/IWebhookResource.cs b/src/Mailtrap.Abstractions/Webhooks/IWebhookResource.cs new file mode 100644 index 00000000..cec1bce6 --- /dev/null +++ b/src/Mailtrap.Abstractions/Webhooks/IWebhookResource.cs @@ -0,0 +1,51 @@ +namespace Mailtrap.Webhooks; + + +/// +/// Represents webhook resource. +/// +public interface IWebhookResource : IRestResource +{ + /// + /// Gets details of the webhook, represented by the current resource instance. + /// + /// + /// + /// Token to control operation cancellation. + /// + /// + /// + /// Requested webhook details. + /// + public Task GetDetails(CancellationToken cancellationToken = default); + + /// + /// Updates the webhook, represented by the current resource instance, with details specified by . + /// + /// + /// + /// Webhook details for update. Only properties set on the request are sent. + /// + /// + /// + /// + /// + /// + /// + /// Updated webhook details. + /// + public Task Update(UpdateWebhookRequest request, CancellationToken cancellationToken = default); + + /// + /// Permanently deletes the webhook, represented by the current resource instance. + /// + /// + /// + /// + /// + /// + /// + /// Deleted webhook details. + /// + public Task Delete(CancellationToken cancellationToken = default); +} diff --git a/src/Mailtrap.Abstractions/Webhooks/Models/Webhook.cs b/src/Mailtrap.Abstractions/Webhooks/Models/Webhook.cs new file mode 100644 index 00000000..fd66d066 --- /dev/null +++ b/src/Mailtrap.Abstractions/Webhooks/Models/Webhook.cs @@ -0,0 +1,100 @@ +namespace Mailtrap.Webhooks.Models; + + +/// +/// Represents webhook details. +/// +public record Webhook +{ + /// + /// Gets or sets webhook identifier. + /// + /// + /// + /// Webhook identifier. + /// + [JsonPropertyName("id")] + [JsonPropertyOrder(1)] + public long Id { get; set; } + + /// + /// Gets or sets the URL that will receive webhook payloads. + /// + /// + /// + /// The URL that will receive webhook payloads. + /// + [JsonPropertyName("url")] + [JsonPropertyOrder(2)] + public Uri? Url { get; set; } + + /// + /// Gets or sets a value indicating whether the webhook is active. + /// + /// + /// + /// when the webhook is active; otherwise. + /// + [JsonPropertyName("active")] + [JsonPropertyOrder(3)] + public bool Active { get; set; } + + /// + /// Gets or sets webhook type. + /// + /// + /// + /// Webhook type. Allowed values: "email_sending", "audit_log". + /// + [JsonPropertyName("webhook_type")] + [JsonPropertyOrder(4)] + public WebhookType WebhookType { get; set; } = WebhookType.Unknown; + + /// + /// Gets or sets the format of the webhook payload. + /// + /// + /// + /// Webhook payload format. Allowed values: "json", "jsonlines". + /// + [JsonPropertyName("payload_format")] + [JsonPropertyOrder(5)] + public WebhookPayloadFormat PayloadFormat { get; set; } = WebhookPayloadFormat.Unknown; + + /// + /// Gets or sets sending stream the webhook is subscribed to.
+ /// Applicable only for email_sending webhooks. + ///
+ /// + /// + /// Sending stream or when not applicable. + /// + [JsonPropertyName("sending_stream")] + [JsonPropertyOrder(6)] + public SendingStream? SendingStream { get; set; } + + /// + /// Gets or sets the domain ID the webhook is scoped to.
+ /// Applicable only for email_sending webhooks; means all domains. + ///
+ /// + /// + /// Domain identifier or when scoped to all domains. + /// + [JsonPropertyName("domain_id")] + [JsonPropertyOrder(7)] + public long? DomainId { get; set; } + + /// + /// Gets or sets the list of event types the webhook is subscribed to.
+ /// Applicable only for email_sending webhooks. + ///
+ /// + /// + /// List of event types. + /// + [JsonPropertyName("event_types")] + [JsonPropertyOrder(8)] + [JsonObjectCreationHandling(JsonObjectCreationHandling.Populate)] + public IList EventTypes { get; } = []; +} diff --git a/src/Mailtrap.Abstractions/Webhooks/Models/WebhookEventType.cs b/src/Mailtrap.Abstractions/Webhooks/Models/WebhookEventType.cs new file mode 100644 index 00000000..f64e4592 --- /dev/null +++ b/src/Mailtrap.Abstractions/Webhooks/Models/WebhookEventType.cs @@ -0,0 +1,53 @@ +namespace Mailtrap.Webhooks.Models; + + +/// +/// Event type the email_sending webhook can subscribe to. +/// +public sealed record WebhookEventType : StringEnum +{ + /// + /// Gets the value representing "delivery" event. + /// + public static readonly WebhookEventType Delivery = Define("delivery"); + + /// + /// Gets the value representing "soft_bounce" event. + /// + public static readonly WebhookEventType SoftBounce = Define("soft_bounce"); + + /// + /// Gets the value representing "bounce" event. + /// + public static readonly WebhookEventType Bounce = Define("bounce"); + + /// + /// Gets the value representing "suspension" event. + /// + public static readonly WebhookEventType Suspension = Define("suspension"); + + /// + /// Gets the value representing "unsubscribe" event. + /// + public static readonly WebhookEventType Unsubscribe = Define("unsubscribe"); + + /// + /// Gets the value representing "open" event. + /// + public static readonly WebhookEventType Open = Define("open"); + + /// + /// Gets the value representing "spam_complaint" event. + /// + public static readonly WebhookEventType SpamComplaint = Define("spam_complaint"); + + /// + /// Gets the value representing "click" event. + /// + public static readonly WebhookEventType Click = Define("click"); + + /// + /// Gets the value representing "reject" event. + /// + public static readonly WebhookEventType Reject = Define("reject"); +} diff --git a/src/Mailtrap.Abstractions/Webhooks/Models/WebhookPayloadFormat.cs b/src/Mailtrap.Abstractions/Webhooks/Models/WebhookPayloadFormat.cs new file mode 100644 index 00000000..faaa3508 --- /dev/null +++ b/src/Mailtrap.Abstractions/Webhooks/Models/WebhookPayloadFormat.cs @@ -0,0 +1,26 @@ +namespace Mailtrap.Webhooks.Models; + + +/// +/// Format of the webhook payload. +/// +public sealed record WebhookPayloadFormat : StringEnum +{ + /// + /// Gets the value representing "json" payload format. + /// + /// + /// + /// Represents "json" payload format. + /// + public static readonly WebhookPayloadFormat Json = Define("json"); + + /// + /// Gets the value representing "jsonlines" payload format. + /// + /// + /// + /// Represents "jsonlines" payload format. + /// + public static readonly WebhookPayloadFormat JsonLines = Define("jsonlines"); +} diff --git a/src/Mailtrap.Abstractions/Webhooks/Models/WebhookType.cs b/src/Mailtrap.Abstractions/Webhooks/Models/WebhookType.cs new file mode 100644 index 00000000..08182caa --- /dev/null +++ b/src/Mailtrap.Abstractions/Webhooks/Models/WebhookType.cs @@ -0,0 +1,26 @@ +namespace Mailtrap.Webhooks.Models; + + +/// +/// Webhook type. Determines which events the webhook can subscribe to. +/// +public sealed record WebhookType : StringEnum +{ + /// + /// Gets the value representing "email_sending" webhook type. + /// + /// + /// + /// Represents "email_sending" webhook type. + /// + public static readonly WebhookType EmailSending = Define("email_sending"); + + /// + /// Gets the value representing "audit_log" webhook type. + /// + /// + /// + /// Represents "audit_log" webhook type. + /// + public static readonly WebhookType AuditLog = Define("audit_log"); +} diff --git a/src/Mailtrap.Abstractions/Webhooks/Requests/CreateWebhookRequest.cs b/src/Mailtrap.Abstractions/Webhooks/Requests/CreateWebhookRequest.cs new file mode 100644 index 00000000..7bae6364 --- /dev/null +++ b/src/Mailtrap.Abstractions/Webhooks/Requests/CreateWebhookRequest.cs @@ -0,0 +1,105 @@ +namespace Mailtrap.Webhooks.Requests; + + +/// +/// Request object for creating a webhook. +/// +public sealed record CreateWebhookRequest : IValidatable +{ + /// + /// Gets or sets the URL that will receive webhook payloads. + /// + /// + /// + /// Target URL. + /// + [JsonPropertyName("url")] + [JsonPropertyOrder(1)] + [JsonRequired] + public Uri? Url { get; set; } + + /// + /// Gets or sets webhook type. + /// + /// + /// + /// Webhook type. Allowed values: "email_sending", "audit_log". + /// + [JsonPropertyName("webhook_type")] + [JsonPropertyOrder(2)] + [JsonRequired] + public WebhookType WebhookType { get; set; } = WebhookType.Unknown; + + /// + /// Gets or sets a value indicating whether the webhook is active. Defaults to . + /// + /// + /// + /// Active flag or to use the API default. + /// + [JsonPropertyName("active")] + [JsonPropertyOrder(3)] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public bool? Active { get; set; } + + /// + /// Gets or sets the format of the webhook payload. + /// + /// + /// + /// Webhook payload format or to use the API default ("json"). + /// + [JsonPropertyName("payload_format")] + [JsonPropertyOrder(4)] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public WebhookPayloadFormat? PayloadFormat { get; set; } + + /// + /// Gets or sets sending stream the webhook subscribes to.
+ /// Required for email_sending webhook type. + ///
+ /// + /// + /// Sending stream or . + /// + [JsonPropertyName("sending_stream")] + [JsonPropertyOrder(5)] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public SendingStream? SendingStream { get; set; } + + /// + /// Gets the list of event types to subscribe to.
+ /// Required for email_sending webhook type. + ///
+ /// + /// + /// List of event types. + /// + [JsonPropertyName("event_types")] + [JsonPropertyOrder(6)] + [JsonObjectCreationHandling(JsonObjectCreationHandling.Populate)] + public IList EventTypes { get; } = []; + + /// + /// Gets or sets the domain ID to scope the webhook to.
+ /// When omitted, the webhook applies to all domains.
+ /// Applicable only for email_sending webhooks. + ///
+ /// + /// + /// Domain identifier or . + /// + [JsonPropertyName("domain_id")] + [JsonPropertyOrder(7)] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public long? DomainId { get; set; } + + + /// + public ValidationResult Validate() + { + return CreateWebhookRequestValidator.Instance + .Validate(this) + .ToMailtrapValidationResult(); + } +} diff --git a/src/Mailtrap.Abstractions/Webhooks/Requests/UpdateWebhookRequest.cs b/src/Mailtrap.Abstractions/Webhooks/Requests/UpdateWebhookRequest.cs new file mode 100644 index 00000000..6acf5dfb --- /dev/null +++ b/src/Mailtrap.Abstractions/Webhooks/Requests/UpdateWebhookRequest.cs @@ -0,0 +1,67 @@ +namespace Mailtrap.Webhooks.Requests; + + +/// +/// Request object for updating a webhook. Only properties set on the request are sent. +/// +public sealed record UpdateWebhookRequest : IValidatable +{ + /// + /// Gets or sets the URL that will receive webhook payloads. + /// + /// + /// + /// Target URL or to leave unchanged. + /// + [JsonPropertyName("url")] + [JsonPropertyOrder(1)] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public Uri? Url { get; set; } + + /// + /// Gets or sets a value indicating whether the webhook is active. + /// + /// + /// + /// Active flag or to leave unchanged. + /// + [JsonPropertyName("active")] + [JsonPropertyOrder(2)] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public bool? Active { get; set; } + + /// + /// Gets or sets the format of the webhook payload. + /// + /// + /// + /// Webhook payload format or to leave unchanged. + /// + [JsonPropertyName("payload_format")] + [JsonPropertyOrder(3)] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public WebhookPayloadFormat? PayloadFormat { get; set; } + + /// + /// Gets or sets the list of event types the webhook subscribes to.
+ /// Applicable only for email_sending webhooks.
+ /// Leave to keep the existing event types unchanged. + ///
+ /// + /// + /// List of event types or to leave unchanged. + /// + [JsonPropertyName("event_types")] + [JsonPropertyOrder(4)] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public IList? EventTypes { get; set; } + + + /// + public ValidationResult Validate() + { + return UpdateWebhookRequestValidator.Instance + .Validate(this) + .ToMailtrapValidationResult(); + } +} diff --git a/src/Mailtrap.Abstractions/Webhooks/Responses/CreateWebhookResponse.cs b/src/Mailtrap.Abstractions/Webhooks/Responses/CreateWebhookResponse.cs new file mode 100644 index 00000000..75304839 --- /dev/null +++ b/src/Mailtrap.Abstractions/Webhooks/Responses/CreateWebhookResponse.cs @@ -0,0 +1,27 @@ +namespace Mailtrap.Webhooks.Responses; + + +/// +/// Response object for webhook creation. +/// +/// +/// +/// Extends with , which is only returned upon webhook creation. +/// +public sealed record CreateWebhookResponse : Webhook +{ + /// + /// Gets or sets the secret key for verifying webhook payload signatures using HMAC SHA-256. + /// + /// + /// + /// Only returned at creation time. Store it securely - it cannot be retrieved later. + /// + /// + /// + /// Signing secret. + /// + [JsonPropertyName("signing_secret")] + [JsonPropertyOrder(9)] + public string SigningSecret { get; set; } = string.Empty; +} diff --git a/src/Mailtrap.Abstractions/Webhooks/Validators/CreateWebhookRequestValidator.cs b/src/Mailtrap.Abstractions/Webhooks/Validators/CreateWebhookRequestValidator.cs new file mode 100644 index 00000000..eb7456f9 --- /dev/null +++ b/src/Mailtrap.Abstractions/Webhooks/Validators/CreateWebhookRequestValidator.cs @@ -0,0 +1,42 @@ +namespace Mailtrap.Webhooks.Validators; + + +/// +/// Validator for .
+/// Ensures Url is an absolute URI, WebhookType is set, +/// and that email_sending webhooks specify SendingStream and EventTypes. +///
+public sealed class CreateWebhookRequestValidator : AbstractValidator +{ + /// + /// Static validator instance for reuse. + /// + public static CreateWebhookRequestValidator Instance { get; } = new(); + + /// + /// Primary constructor. + /// + public CreateWebhookRequestValidator() + { + RuleFor(r => r.Url) + .NotNull() + .Must(u => u is { IsAbsoluteUri: true }) + .WithMessage("'Url' must be an absolute URI."); + + RuleFor(r => r.WebhookType) + .NotNull() + .Must(t => t != WebhookType.None && t != WebhookType.Unknown) + .WithMessage("'WebhookType' must be set to a valid value."); + + When(r => r.WebhookType == WebhookType.EmailSending, () => + { + RuleFor(r => r.SendingStream) + .NotNull() + .WithMessage("'SendingStream' is required for 'email_sending' webhook type."); + + RuleFor(r => r.EventTypes) + .Must(events => events is { Count: > 0 }) + .WithMessage("'EventTypes' must contain at least one event for 'email_sending' webhook type."); + }); + } +} diff --git a/src/Mailtrap.Abstractions/Webhooks/Validators/UpdateWebhookRequestValidator.cs b/src/Mailtrap.Abstractions/Webhooks/Validators/UpdateWebhookRequestValidator.cs new file mode 100644 index 00000000..85e6742f --- /dev/null +++ b/src/Mailtrap.Abstractions/Webhooks/Validators/UpdateWebhookRequestValidator.cs @@ -0,0 +1,27 @@ +namespace Mailtrap.Webhooks.Validators; + + +/// +/// Validator for .
+/// Ensures Url, when provided, is an absolute URI. +///
+public sealed class UpdateWebhookRequestValidator : AbstractValidator +{ + /// + /// Static validator instance for reuse. + /// + public static UpdateWebhookRequestValidator Instance { get; } = new(); + + /// + /// Primary constructor. + /// + public UpdateWebhookRequestValidator() + { + When(r => r.Url is not null, () => + { + RuleFor(r => r.Url) + .Must(u => u is { IsAbsoluteUri: true }) + .WithMessage("'Url' must be an absolute URI."); + }); + } +} diff --git a/src/Mailtrap/Accounts/AccountResource.cs b/src/Mailtrap/Accounts/AccountResource.cs index 003d2a76..2b596b47 100644 --- a/src/Mailtrap/Accounts/AccountResource.cs +++ b/src/Mailtrap/Accounts/AccountResource.cs @@ -120,4 +120,32 @@ public IEmailLogResource EmailLog(string sendingMessageId) } #endregion + + #region API Tokens + + public IApiTokenCollectionResource ApiTokens() + => new ApiTokenCollectionResource(RestResourceCommandFactory, ResourceUri.Append(UrlSegments.ApiTokensSegment)); + + public IApiTokenResource ApiToken(long apiTokenId) + { + Ensure.GreaterThanZero(apiTokenId, nameof(apiTokenId)); + + return new ApiTokenResource(RestResourceCommandFactory, ResourceUri.Append(UrlSegments.ApiTokensSegment).Append(apiTokenId)); + } + + #endregion + + #region Webhooks + + public IWebhookCollectionResource Webhooks() + => new WebhookCollectionResource(RestResourceCommandFactory, ResourceUri.Append(UrlSegments.WebhooksSegment)); + + public IWebhookResource Webhook(long webhookId) + { + Ensure.GreaterThanZero(webhookId, nameof(webhookId)); + + return new WebhookResource(RestResourceCommandFactory, ResourceUri.Append(UrlSegments.WebhooksSegment).Append(webhookId)); + } + + #endregion } diff --git a/src/Mailtrap/ApiTokens/ApiTokenCollectionResource.cs b/src/Mailtrap/ApiTokens/ApiTokenCollectionResource.cs new file mode 100644 index 00000000..bfae88e8 --- /dev/null +++ b/src/Mailtrap/ApiTokens/ApiTokenCollectionResource.cs @@ -0,0 +1,15 @@ +namespace Mailtrap.ApiTokens; + + +internal sealed class ApiTokenCollectionResource : RestResource, IApiTokenCollectionResource +{ + public ApiTokenCollectionResource(IRestResourceCommandFactory restResourceCommandFactory, Uri resourceUri) + : base(restResourceCommandFactory, resourceUri) { } + + + public async Task> GetAll(CancellationToken cancellationToken = default) + => await GetList(cancellationToken).ConfigureAwait(false); + + public async Task Create(CreateApiTokenRequest request, CancellationToken cancellationToken = default) + => await Create(request, cancellationToken).ConfigureAwait(false); +} diff --git a/src/Mailtrap/ApiTokens/ApiTokenResource.cs b/src/Mailtrap/ApiTokens/ApiTokenResource.cs new file mode 100644 index 00000000..a7b1a511 --- /dev/null +++ b/src/Mailtrap/ApiTokens/ApiTokenResource.cs @@ -0,0 +1,30 @@ +namespace Mailtrap.ApiTokens; + + +internal sealed class ApiTokenResource : RestResource, IApiTokenResource +{ + private const string ResetSegment = "reset"; + + + public ApiTokenResource(IRestResourceCommandFactory restResourceCommandFactory, Uri resourceUri) + : base(restResourceCommandFactory, resourceUri) { } + + + public async Task GetDetails(CancellationToken cancellationToken = default) + => await Get(cancellationToken).ConfigureAwait(false); + + public async Task Delete(CancellationToken cancellationToken = default) + => await DeleteWithStatusCodeResult(cancellationToken).ConfigureAwait(false); + + public async Task Reset(CancellationToken cancellationToken = default) + { + var uri = ResourceUri.Append(ResetSegment); + + var result = await RestResourceCommandFactory + .CreatePost(uri) + .Execute(cancellationToken) + .ConfigureAwait(false); + + return result; + } +} diff --git a/src/Mailtrap/Core/Constants/UrlSegments.cs b/src/Mailtrap/Core/Constants/UrlSegments.cs index 73fc0707..079c08fd 100644 --- a/src/Mailtrap/Core/Constants/UrlSegments.cs +++ b/src/Mailtrap/Core/Constants/UrlSegments.cs @@ -16,4 +16,8 @@ internal static class UrlSegments internal static string SuppressionsSegment { get; } = "suppressions"; internal static string StatsSegment { get; } = "stats"; internal static string EmailLogsSegment { get; } = "email_logs"; + internal static string ApiTokensSegment { get; } = "api_tokens"; + internal static string OrganizationsSegment { get; } = "organizations"; + internal static string SubAccountsSegment { get; } = "sub_accounts"; + internal static string WebhooksSegment { get; } = "webhooks"; } diff --git a/src/Mailtrap/Core/Rest/Commands/PostWithoutBodyRestResourceCommand.cs b/src/Mailtrap/Core/Rest/Commands/PostWithoutBodyRestResourceCommand.cs new file mode 100644 index 00000000..2c7b5da2 --- /dev/null +++ b/src/Mailtrap/Core/Rest/Commands/PostWithoutBodyRestResourceCommand.cs @@ -0,0 +1,18 @@ +namespace Mailtrap.Core.Rest.Commands; + + +internal sealed class PostWithoutBodyRestResourceCommand : RestResourceCommand +{ + public PostWithoutBodyRestResourceCommand( + IHttpClientProvider httpClientProvider, + IHttpRequestMessageFactory httpRequestMessageFactory, + IHttpResponseHandlerFactory httpResponseHandlerFactory, + Uri resourceUri) + : base( + httpClientProvider, + httpRequestMessageFactory, + httpResponseHandlerFactory, + resourceUri, + HttpMethod.Post) + { } +} diff --git a/src/Mailtrap/Core/Rest/IRestResourceCommandFactory.cs b/src/Mailtrap/Core/Rest/IRestResourceCommandFactory.cs index ec544c94..6975271a 100644 --- a/src/Mailtrap/Core/Rest/IRestResourceCommandFactory.cs +++ b/src/Mailtrap/Core/Rest/IRestResourceCommandFactory.cs @@ -10,6 +10,7 @@ internal interface IRestResourceCommandFactory public IRestResourceCommand CreateDeleteWithStatusCodeResult(Uri resourceUri); public IRestResourceCommand CreatePostWithStatusCodeResult(Uri resourceUri, TRequest request) where TRequest : class; public IRestResourceCommand CreatePost(Uri resourceUri, TRequest request) where TRequest : class; + public IRestResourceCommand CreatePost(Uri resourceUri); public IRestResourceCommand CreatePut(Uri resourceUri, TRequest request) where TRequest : class; public IRestResourceCommand CreatePatchWithContent(Uri resourceUri, TRequest request) where TRequest : class; } diff --git a/src/Mailtrap/Core/Rest/RestResourceCommandFactory.cs b/src/Mailtrap/Core/Rest/RestResourceCommandFactory.cs index 373c2a2b..dccd1dbb 100644 --- a/src/Mailtrap/Core/Rest/RestResourceCommandFactory.cs +++ b/src/Mailtrap/Core/Rest/RestResourceCommandFactory.cs @@ -78,6 +78,13 @@ public IRestResourceCommand CreatePost(Uri resou resourceUri, request); + public IRestResourceCommand CreatePost(Uri resourceUri) + => new PostWithoutBodyRestResourceCommand( + _httpClientProvider, + _httpRequestMessageFactory, + _httpResponseHandlerFactory, + resourceUri); + public IRestResourceCommand CreatePut(Uri resourceUri, TRequest request) where TRequest : class => new PutRestResourceCommand( diff --git a/src/Mailtrap/GlobalUsings.cs b/src/Mailtrap/GlobalUsings.cs index aa3c688d..42e992a6 100644 --- a/src/Mailtrap/GlobalUsings.cs +++ b/src/Mailtrap/GlobalUsings.cs @@ -13,6 +13,10 @@ global using Mailtrap.AccountAccesses.Responses; global using Mailtrap.Accounts; global using Mailtrap.Accounts.Models; +global using Mailtrap.ApiTokens; +global using Mailtrap.ApiTokens.Models; +global using Mailtrap.ApiTokens.Requests; +global using Mailtrap.ApiTokens.Responses; global using Mailtrap.Attachments; global using Mailtrap.Attachments.Models; global using Mailtrap.Billing; @@ -61,6 +65,9 @@ global using Mailtrap.Inboxes; global using Mailtrap.Inboxes.Models; global using Mailtrap.Inboxes.Requests; +global using Mailtrap.Organizations; +global using Mailtrap.Organizations.Models; +global using Mailtrap.Organizations.Requests; global using Mailtrap.Permissions; global using Mailtrap.Permissions.Models; global using Mailtrap.Projects; @@ -78,6 +85,10 @@ global using Mailtrap.TestingMessages.Models; global using Mailtrap.TestingMessages.Requests; global using Mailtrap.TestingMessages.Responses; +global using Mailtrap.Webhooks; +global using Mailtrap.Webhooks.Models; +global using Mailtrap.Webhooks.Requests; +global using Mailtrap.Webhooks.Responses; global using Microsoft.Extensions.Configuration; global using Microsoft.Extensions.DependencyInjection; global using Microsoft.Extensions.DependencyInjection.Extensions; diff --git a/src/Mailtrap/MailtrapClientFactory.cs b/src/Mailtrap/MailtrapClientFactory.cs index c42349f6..abf04805 100644 --- a/src/Mailtrap/MailtrapClientFactory.cs +++ b/src/Mailtrap/MailtrapClientFactory.cs @@ -156,6 +156,9 @@ public MailtrapClientFactory( /// public IMailtrapClient CreateClient() => _serviceProvider.GetRequiredService(); + /// + public IMailtrapOrganizationClient CreateOrganizationClient() => _serviceProvider.GetRequiredService(); + #endregion diff --git a/src/Mailtrap/MailtrapClientServiceCollectionExtensions.cs b/src/Mailtrap/MailtrapClientServiceCollectionExtensions.cs index 2eaafaeb..ce5d9033 100644 --- a/src/Mailtrap/MailtrapClientServiceCollectionExtensions.cs +++ b/src/Mailtrap/MailtrapClientServiceCollectionExtensions.cs @@ -144,6 +144,7 @@ internal static IServiceCollection AddMailtrapServices(this IServiceCollectio services.TryAddTransient(services => services.GetRequiredService().CreateBatchDefault()); services.TryAddTransient(); + services.TryAddTransient(); return services; } diff --git a/src/Mailtrap/MailtrapOrganizationClient.cs b/src/Mailtrap/MailtrapOrganizationClient.cs new file mode 100644 index 00000000..1011866e --- /dev/null +++ b/src/Mailtrap/MailtrapOrganizationClient.cs @@ -0,0 +1,20 @@ +namespace Mailtrap; + + +/// +/// implementation. +/// +internal sealed class MailtrapOrganizationClient : RestResource, IMailtrapOrganizationClient +{ + public MailtrapOrganizationClient(IRestResourceCommandFactory restResourceCommandFactory) + : base(restResourceCommandFactory, Endpoints.ApiDefaultUrl.Append(UrlSegments.ApiRootSegment)) + { } + + + public IOrganizationResource Organization(long organizationId) + { + Ensure.GreaterThanZero(organizationId, nameof(organizationId)); + + return new OrganizationResource(RestResourceCommandFactory, ResourceUri.Append(UrlSegments.OrganizationsSegment).Append(organizationId)); + } +} diff --git a/src/Mailtrap/Organizations/OrganizationResource.cs b/src/Mailtrap/Organizations/OrganizationResource.cs new file mode 100644 index 00000000..6c5c92f5 --- /dev/null +++ b/src/Mailtrap/Organizations/OrganizationResource.cs @@ -0,0 +1,12 @@ +namespace Mailtrap.Organizations; + + +internal sealed class OrganizationResource : RestResource, IOrganizationResource +{ + public OrganizationResource(IRestResourceCommandFactory restResourceCommandFactory, Uri resourceUri) + : base(restResourceCommandFactory, resourceUri) { } + + + public IOrganizationSubAccountCollectionResource SubAccounts() + => new OrganizationSubAccountCollectionResource(RestResourceCommandFactory, ResourceUri.Append(UrlSegments.SubAccountsSegment)); +} diff --git a/src/Mailtrap/Organizations/OrganizationSubAccountCollectionResource.cs b/src/Mailtrap/Organizations/OrganizationSubAccountCollectionResource.cs new file mode 100644 index 00000000..3dee8913 --- /dev/null +++ b/src/Mailtrap/Organizations/OrganizationSubAccountCollectionResource.cs @@ -0,0 +1,15 @@ +namespace Mailtrap.Organizations; + + +internal sealed class OrganizationSubAccountCollectionResource : RestResource, IOrganizationSubAccountCollectionResource +{ + public OrganizationSubAccountCollectionResource(IRestResourceCommandFactory restResourceCommandFactory, Uri resourceUri) + : base(restResourceCommandFactory, resourceUri) { } + + + public async Task> GetAll(CancellationToken cancellationToken = default) + => await GetList(cancellationToken).ConfigureAwait(false); + + public async Task Create(CreateSubAccountRequest request, CancellationToken cancellationToken = default) + => await Create(request, cancellationToken).ConfigureAwait(false); +} diff --git a/src/Mailtrap/Webhooks/Requests/CreateWebhookRequestDto.cs b/src/Mailtrap/Webhooks/Requests/CreateWebhookRequestDto.cs new file mode 100644 index 00000000..9f2337e3 --- /dev/null +++ b/src/Mailtrap/Webhooks/Requests/CreateWebhookRequestDto.cs @@ -0,0 +1,23 @@ +namespace Mailtrap.Webhooks.Requests; + + +/// +/// Request DTO that wraps in the webhook envelope expected by the API. +/// +internal sealed record CreateWebhookRequestDto : IValidatable +{ + [JsonPropertyName("webhook")] + [JsonPropertyOrder(1)] + public CreateWebhookRequest Webhook { get; } + + + public CreateWebhookRequestDto(CreateWebhookRequest webhook) + { + Ensure.NotNull(webhook, nameof(webhook)); + + Webhook = webhook; + } + + + public ValidationResult Validate() => Webhook.Validate(); +} diff --git a/src/Mailtrap/Webhooks/Requests/UpdateWebhookRequestDto.cs b/src/Mailtrap/Webhooks/Requests/UpdateWebhookRequestDto.cs new file mode 100644 index 00000000..6a1a7f0c --- /dev/null +++ b/src/Mailtrap/Webhooks/Requests/UpdateWebhookRequestDto.cs @@ -0,0 +1,23 @@ +namespace Mailtrap.Webhooks.Requests; + + +/// +/// Request DTO that wraps in the webhook envelope expected by the API. +/// +internal sealed record UpdateWebhookRequestDto : IValidatable +{ + [JsonPropertyName("webhook")] + [JsonPropertyOrder(1)] + public UpdateWebhookRequest Webhook { get; } + + + public UpdateWebhookRequestDto(UpdateWebhookRequest webhook) + { + Ensure.NotNull(webhook, nameof(webhook)); + + Webhook = webhook; + } + + + public ValidationResult Validate() => Webhook.Validate(); +} diff --git a/src/Mailtrap/Webhooks/Requests/WebhookRequestExtensions.cs b/src/Mailtrap/Webhooks/Requests/WebhookRequestExtensions.cs new file mode 100644 index 00000000..d2e5da0b --- /dev/null +++ b/src/Mailtrap/Webhooks/Requests/WebhookRequestExtensions.cs @@ -0,0 +1,9 @@ +namespace Mailtrap.Webhooks.Requests; + + +internal static class WebhookRequestExtensions +{ + public static CreateWebhookRequestDto ToDto(this CreateWebhookRequest request) => new(request); + + public static UpdateWebhookRequestDto ToDto(this UpdateWebhookRequest request) => new(request); +} diff --git a/src/Mailtrap/Webhooks/Responses/CreateWebhookResponseDto.cs b/src/Mailtrap/Webhooks/Responses/CreateWebhookResponseDto.cs new file mode 100644 index 00000000..0ec7c290 --- /dev/null +++ b/src/Mailtrap/Webhooks/Responses/CreateWebhookResponseDto.cs @@ -0,0 +1,13 @@ +namespace Mailtrap.Webhooks.Responses; + + +/// +/// Response DTO that unwraps the created webhook (including signing_secret) from the data envelope. +/// +internal sealed record CreateWebhookResponseDto +{ + [JsonPropertyName("data")] + [JsonPropertyOrder(1)] + [JsonObjectCreationHandling(JsonObjectCreationHandling.Populate)] + public CreateWebhookResponse Webhook { get; } = new(); +} diff --git a/src/Mailtrap/Webhooks/Responses/WebhookListResponseDto.cs b/src/Mailtrap/Webhooks/Responses/WebhookListResponseDto.cs new file mode 100644 index 00000000..a30523eb --- /dev/null +++ b/src/Mailtrap/Webhooks/Responses/WebhookListResponseDto.cs @@ -0,0 +1,13 @@ +namespace Mailtrap.Webhooks.Responses; + + +/// +/// Response DTO that unwraps the list of webhooks from the data envelope. +/// +internal sealed record WebhookListResponseDto +{ + [JsonPropertyName("data")] + [JsonPropertyOrder(1)] + [JsonObjectCreationHandling(JsonObjectCreationHandling.Populate)] + public IList Webhooks { get; } = []; +} diff --git a/src/Mailtrap/Webhooks/Responses/WebhookResponseDto.cs b/src/Mailtrap/Webhooks/Responses/WebhookResponseDto.cs new file mode 100644 index 00000000..98a00455 --- /dev/null +++ b/src/Mailtrap/Webhooks/Responses/WebhookResponseDto.cs @@ -0,0 +1,13 @@ +namespace Mailtrap.Webhooks.Responses; + + +/// +/// Response DTO that unwraps a single webhook from the data envelope. +/// +internal sealed record WebhookResponseDto +{ + [JsonPropertyName("data")] + [JsonPropertyOrder(1)] + [JsonObjectCreationHandling(JsonObjectCreationHandling.Populate)] + public Webhook Webhook { get; } = new(); +} diff --git a/src/Mailtrap/Webhooks/WebhookCollectionResource.cs b/src/Mailtrap/Webhooks/WebhookCollectionResource.cs new file mode 100644 index 00000000..a6490203 --- /dev/null +++ b/src/Mailtrap/Webhooks/WebhookCollectionResource.cs @@ -0,0 +1,25 @@ +namespace Mailtrap.Webhooks; + + +internal sealed class WebhookCollectionResource : RestResource, IWebhookCollectionResource +{ + public WebhookCollectionResource(IRestResourceCommandFactory restResourceCommandFactory, Uri resourceUri) + : base(restResourceCommandFactory, resourceUri) { } + + + public async Task> GetAll(CancellationToken cancellationToken = default) + { + var response = await Get(cancellationToken).ConfigureAwait(false); + + return response.Webhooks; + } + + public async Task Create(CreateWebhookRequest request, CancellationToken cancellationToken = default) + { + Ensure.NotNull(request, nameof(request)); + + var response = await Create(request.ToDto(), cancellationToken).ConfigureAwait(false); + + return response.Webhook; + } +} diff --git a/src/Mailtrap/Webhooks/WebhookResource.cs b/src/Mailtrap/Webhooks/WebhookResource.cs new file mode 100644 index 00000000..da6f6550 --- /dev/null +++ b/src/Mailtrap/Webhooks/WebhookResource.cs @@ -0,0 +1,32 @@ +namespace Mailtrap.Webhooks; + + +internal sealed class WebhookResource : RestResource, IWebhookResource +{ + public WebhookResource(IRestResourceCommandFactory restResourceCommandFactory, Uri resourceUri) + : base(restResourceCommandFactory, resourceUri) { } + + + public async Task GetDetails(CancellationToken cancellationToken = default) + { + var response = await Get(cancellationToken).ConfigureAwait(false); + + return response.Webhook; + } + + public async Task Update(UpdateWebhookRequest request, CancellationToken cancellationToken = default) + { + Ensure.NotNull(request, nameof(request)); + + var response = await Update(request.ToDto(), cancellationToken).ConfigureAwait(false); + + return response.Webhook; + } + + public async Task Delete(CancellationToken cancellationToken = default) + { + var response = await Delete(cancellationToken).ConfigureAwait(false); + + return response.Webhook; + } +} diff --git a/tests/Mailtrap.IntegrationTests/ApiTokens/ApiTokensIntegrationTests.cs b/tests/Mailtrap.IntegrationTests/ApiTokens/ApiTokensIntegrationTests.cs new file mode 100644 index 00000000..edd340e3 --- /dev/null +++ b/tests/Mailtrap.IntegrationTests/ApiTokens/ApiTokensIntegrationTests.cs @@ -0,0 +1,185 @@ +namespace Mailtrap.IntegrationTests.ApiTokens; + + +[TestFixture] +internal sealed class ApiTokensIntegrationTests +{ + private const string Feature = "ApiTokens"; + private const string ResetSegment = "reset"; + + private readonly long _accountId; + private readonly Uri _resourceUri; + private readonly MailtrapClientOptions _clientConfig; + private readonly JsonSerializerOptions _jsonSerializerOptions; + + public ApiTokensIntegrationTests() + { + var random = TestContext.CurrentContext.Random; + + _accountId = random.NextLong(); + _resourceUri = EndpointsTestConstants.ApiDefaultUrl + .Append( + UrlSegmentsTestConstants.ApiRootSegment, + UrlSegmentsTestConstants.AccountsSegment) + .Append(_accountId) + .Append(UrlSegmentsTestConstants.ApiTokensSegment); + + var token = random.GetString(); + _clientConfig = new MailtrapClientOptions(token); + _jsonSerializerOptions = _clientConfig.ToJsonSerializerOptions(); + } + + + [Test] + public async Task GetAll_Success() + { + // Arrange + using var responseContent = await Feature.LoadFileToStringContent(); + var expectedResponse = await responseContent.DeserializeStringContentAsync>(_jsonSerializerOptions); + expectedResponse.Should().NotBeNull(); + + using var mockHttp = new MockHttpMessageHandler(); + using var clientScope = mockHttp.ConfigureAndCreateClient( + HttpMethod.Get, + _resourceUri.AbsoluteUri, + responseContent, + HttpStatusCode.OK, + _clientConfig); + + // Act + var result = await clientScope.Client + .Account(_accountId) + .ApiTokens() + .GetAll() + .ConfigureAwait(false); + + // Assert + mockHttp.VerifyNoOutstandingExpectation(); + + result.Should().BeEquivalentTo(expectedResponse); + } + + [Test] + public async Task Create_Success() + { + // Arrange + var request = new CreateApiTokenRequest { Name = "My API Token" }; + request.Resources.Add(new ApiTokenAccessRequest(ResourceType.Account, 3229, AccessLevel.Admin)); + + using var responseContent = await Feature.LoadFileToStringContent(); + var expectedResponse = await responseContent.DeserializeStringContentAsync(_jsonSerializerOptions); + expectedResponse.Should().NotBeNull(); + + using var mockHttp = new MockHttpMessageHandler(); + using var clientScope = mockHttp.ConfigureAndCreateClient( + HttpMethod.Post, + _resourceUri.AbsoluteUri, + responseContent, + HttpStatusCode.OK, + _clientConfig); + + // Act + var result = await clientScope.Client + .Account(_accountId) + .ApiTokens() + .Create(request) + .ConfigureAwait(false); + + // Assert + mockHttp.VerifyNoOutstandingExpectation(); + + result.Should().BeEquivalentTo(expectedResponse); + } + + [Test] + public async Task GetDetails_Success() + { + // Arrange + var apiTokenId = TestContext.CurrentContext.Random.NextLong(); + var requestUri = _resourceUri.Append(apiTokenId).AbsoluteUri; + + using var responseContent = await Feature.LoadFileToStringContent(); + var expectedResponse = await responseContent.DeserializeStringContentAsync(_jsonSerializerOptions); + expectedResponse.Should().NotBeNull(); + + using var mockHttp = new MockHttpMessageHandler(); + using var clientScope = mockHttp.ConfigureAndCreateClient( + HttpMethod.Get, + requestUri, + responseContent, + HttpStatusCode.OK, + _clientConfig); + + // Act + var result = await clientScope.Client + .Account(_accountId) + .ApiToken(apiTokenId) + .GetDetails() + .ConfigureAwait(false); + + // Assert + mockHttp.VerifyNoOutstandingExpectation(); + + result.Should().BeEquivalentTo(expectedResponse); + } + + [Test] + public async Task Delete_Success() + { + // Arrange + var apiTokenId = TestContext.CurrentContext.Random.NextLong(); + var requestUri = _resourceUri.Append(apiTokenId).AbsoluteUri; + + using var responseContent = new StringContent(string.Empty); + + using var mockHttp = new MockHttpMessageHandler(); + using var clientScope = mockHttp.ConfigureAndCreateClient( + HttpMethod.Delete, + requestUri, + responseContent, + HttpStatusCode.NoContent, + _clientConfig); + + // Act + await clientScope.Client + .Account(_accountId) + .ApiToken(apiTokenId) + .Delete() + .ConfigureAwait(false); + + // Assert + mockHttp.VerifyNoOutstandingExpectation(); + } + + [Test] + public async Task Reset_Success() + { + // Arrange + var apiTokenId = TestContext.CurrentContext.Random.NextLong(); + var requestUri = _resourceUri.Append(apiTokenId).Append(ResetSegment).AbsoluteUri; + + using var responseContent = await Feature.LoadFileToStringContent(); + var expectedResponse = await responseContent.DeserializeStringContentAsync(_jsonSerializerOptions); + expectedResponse.Should().NotBeNull(); + + using var mockHttp = new MockHttpMessageHandler(); + using var clientScope = mockHttp.ConfigureAndCreateClient( + HttpMethod.Post, + requestUri, + responseContent, + HttpStatusCode.OK, + _clientConfig); + + // Act + var result = await clientScope.Client + .Account(_accountId) + .ApiToken(apiTokenId) + .Reset() + .ConfigureAwait(false); + + // Assert + mockHttp.VerifyNoOutstandingExpectation(); + + result.Should().BeEquivalentTo(expectedResponse); + } +} diff --git a/tests/Mailtrap.IntegrationTests/ApiTokens/Create_Success.json b/tests/Mailtrap.IntegrationTests/ApiTokens/Create_Success.json new file mode 100644 index 00000000..14b63ae6 --- /dev/null +++ b/tests/Mailtrap.IntegrationTests/ApiTokens/Create_Success.json @@ -0,0 +1,15 @@ +{ + "id": 12345, + "name": "My API Token", + "last_4_digits": "x7k9", + "created_by": "user@example.com", + "expires_at": null, + "resources": [ + { + "resource_type": "account", + "resource_id": 3229, + "access_level": 100 + } + ], + "token": "a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6" +} diff --git a/tests/Mailtrap.IntegrationTests/ApiTokens/GetAll_Success.json b/tests/Mailtrap.IntegrationTests/ApiTokens/GetAll_Success.json new file mode 100644 index 00000000..d68c5dd9 --- /dev/null +++ b/tests/Mailtrap.IntegrationTests/ApiTokens/GetAll_Success.json @@ -0,0 +1,35 @@ +[ + { + "id": 12345, + "name": "Production token", + "last_4_digits": "x7k9", + "created_by": "user@example.com", + "expires_at": null, + "resources": [ + { + "resource_type": "account", + "resource_id": 3229, + "access_level": 100 + } + ] + }, + { + "id": 12346, + "name": "Read-only token", + "last_4_digits": "ab12", + "created_by": "owner@example.com", + "expires_at": "2026-12-31T23:59:59Z", + "resources": [ + { + "resource_type": "project", + "resource_id": 99, + "access_level": 10 + }, + { + "resource_type": "inbox", + "resource_id": 4567, + "access_level": 10 + } + ] + } +] diff --git a/tests/Mailtrap.IntegrationTests/ApiTokens/GetDetails_Success.json b/tests/Mailtrap.IntegrationTests/ApiTokens/GetDetails_Success.json new file mode 100644 index 00000000..5cbe901a --- /dev/null +++ b/tests/Mailtrap.IntegrationTests/ApiTokens/GetDetails_Success.json @@ -0,0 +1,14 @@ +{ + "id": 12345, + "name": "Production token", + "last_4_digits": "x7k9", + "created_by": "user@example.com", + "expires_at": null, + "resources": [ + { + "resource_type": "account", + "resource_id": 3229, + "access_level": 100 + } + ] +} diff --git a/tests/Mailtrap.IntegrationTests/ApiTokens/Reset_Success.json b/tests/Mailtrap.IntegrationTests/ApiTokens/Reset_Success.json new file mode 100644 index 00000000..2d0e46b7 --- /dev/null +++ b/tests/Mailtrap.IntegrationTests/ApiTokens/Reset_Success.json @@ -0,0 +1,15 @@ +{ + "id": 12345, + "name": "Production token", + "last_4_digits": "z9y8", + "created_by": "user@example.com", + "expires_at": null, + "resources": [ + { + "resource_type": "account", + "resource_id": 3229, + "access_level": 100 + } + ], + "token": "newtokenvaluereturnedonresetonceonly1234" +} diff --git a/tests/Mailtrap.IntegrationTests/GlobalUsings.cs b/tests/Mailtrap.IntegrationTests/GlobalUsings.cs index e0122a80..c9bbcf8a 100644 --- a/tests/Mailtrap.IntegrationTests/GlobalUsings.cs +++ b/tests/Mailtrap.IntegrationTests/GlobalUsings.cs @@ -7,6 +7,9 @@ global using Mailtrap.AccountAccesses.Requests; global using Mailtrap.AccountAccesses.Responses; global using Mailtrap.Accounts.Models; +global using Mailtrap.ApiTokens.Models; +global using Mailtrap.ApiTokens.Requests; +global using Mailtrap.ApiTokens.Responses; global using Mailtrap.Attachments.Models; global using Mailtrap.Configuration; global using Mailtrap.Core.Constants; @@ -32,6 +35,8 @@ global using Mailtrap.EmailTemplates.Requests; global using Mailtrap.Inboxes.Models; global using Mailtrap.Inboxes.Requests; +global using Mailtrap.Organizations.Models; +global using Mailtrap.Organizations.Requests; global using Mailtrap.IntegrationTests.TestConstants; global using Mailtrap.IntegrationTests.TestExtensions; global using Mailtrap.Projects.Requests; diff --git a/tests/Mailtrap.IntegrationTests/Organizations/Create_Success.json b/tests/Mailtrap.IntegrationTests/Organizations/Create_Success.json new file mode 100644 index 00000000..71a4586a --- /dev/null +++ b/tests/Mailtrap.IntegrationTests/Organizations/Create_Success.json @@ -0,0 +1 @@ +{ "id": 12347, "name": "New Team Account" } diff --git a/tests/Mailtrap.IntegrationTests/Organizations/GetAll_Success.json b/tests/Mailtrap.IntegrationTests/Organizations/GetAll_Success.json new file mode 100644 index 00000000..9ee7eaaa --- /dev/null +++ b/tests/Mailtrap.IntegrationTests/Organizations/GetAll_Success.json @@ -0,0 +1,4 @@ +[ + { "id": 12345, "name": "Development Team Account" }, + { "id": 12346, "name": "QA Team Account" } +] diff --git a/tests/Mailtrap.IntegrationTests/Organizations/OrganizationsIntegrationTests.cs b/tests/Mailtrap.IntegrationTests/Organizations/OrganizationsIntegrationTests.cs new file mode 100644 index 00000000..96d73dad --- /dev/null +++ b/tests/Mailtrap.IntegrationTests/Organizations/OrganizationsIntegrationTests.cs @@ -0,0 +1,91 @@ +namespace Mailtrap.IntegrationTests.Organizations; + + +[TestFixture] +internal sealed class OrganizationsIntegrationTests +{ + private const string Feature = "Organizations"; + + private readonly long _organizationId; + private readonly Uri _resourceUri; + private readonly MailtrapClientOptions _clientConfig; + private readonly JsonSerializerOptions _jsonSerializerOptions; + + public OrganizationsIntegrationTests() + { + var random = TestContext.CurrentContext.Random; + + _organizationId = random.NextLong(); + _resourceUri = EndpointsTestConstants.ApiDefaultUrl + .Append( + UrlSegmentsTestConstants.ApiRootSegment, + UrlSegmentsTestConstants.OrganizationsSegment) + .Append(_organizationId) + .Append(UrlSegmentsTestConstants.SubAccountsSegment); + + var token = random.GetString(); + _clientConfig = new MailtrapClientOptions(token); + _jsonSerializerOptions = _clientConfig.ToJsonSerializerOptions(); + } + + + [Test] + public async Task GetAll_Success() + { + // Arrange + using var responseContent = await Feature.LoadFileToStringContent(); + var expectedResponse = await responseContent.DeserializeStringContentAsync>(_jsonSerializerOptions); + expectedResponse.Should().NotBeNull(); + + using var mockHttp = new MockHttpMessageHandler(); + using var clientScope = mockHttp.ConfigureAndCreateClient( + HttpMethod.Get, + _resourceUri.AbsoluteUri, + responseContent, + HttpStatusCode.OK, + _clientConfig); + + // Act + var result = await clientScope.OrganizationClient + .Organization(_organizationId) + .SubAccounts() + .GetAll() + .ConfigureAwait(false); + + // Assert + mockHttp.VerifyNoOutstandingExpectation(); + + result.Should().BeEquivalentTo(expectedResponse); + } + + [Test] + public async Task Create_Success() + { + // Arrange + var request = new CreateSubAccountRequest { Account = new SubAccountAttributes { Name = "New Team Account" } }; + + using var responseContent = await Feature.LoadFileToStringContent(); + var expectedResponse = await responseContent.DeserializeStringContentAsync(_jsonSerializerOptions); + expectedResponse.Should().NotBeNull(); + + using var mockHttp = new MockHttpMessageHandler(); + using var clientScope = mockHttp.ConfigureAndCreateClient( + HttpMethod.Post, + _resourceUri.AbsoluteUri, + responseContent, + HttpStatusCode.OK, + _clientConfig); + + // Act + var result = await clientScope.OrganizationClient + .Organization(_organizationId) + .SubAccounts() + .Create(request) + .ConfigureAwait(false); + + // Assert + mockHttp.VerifyNoOutstandingExpectation(); + + result.Should().BeEquivalentTo(expectedResponse); + } +} diff --git a/tests/Mailtrap.IntegrationTests/TestConstants/UrlSegmentsTestConstants.cs b/tests/Mailtrap.IntegrationTests/TestConstants/UrlSegmentsTestConstants.cs index edf6f5ac..d4b91515 100644 --- a/tests/Mailtrap.IntegrationTests/TestConstants/UrlSegmentsTestConstants.cs +++ b/tests/Mailtrap.IntegrationTests/TestConstants/UrlSegmentsTestConstants.cs @@ -27,4 +27,7 @@ internal static class UrlSegmentsTestConstants internal static string EventsSegment { get; } = "events"; internal static string BatchEmailSegment { get; } = "batch"; internal static string StatsSegment { get; } = "stats"; + internal static string ApiTokensSegment { get; } = "api_tokens"; + internal static string OrganizationsSegment { get; } = "organizations"; + internal static string SubAccountsSegment { get; } = "sub_accounts"; } diff --git a/tests/Mailtrap.IntegrationTests/TestExtensions/MockHttpHandlerHelpers.cs b/tests/Mailtrap.IntegrationTests/TestExtensions/MockHttpHandlerHelpers.cs index 7f342bbd..bc453122 100644 --- a/tests/Mailtrap.IntegrationTests/TestExtensions/MockHttpHandlerHelpers.cs +++ b/tests/Mailtrap.IntegrationTests/TestExtensions/MockHttpHandlerHelpers.cs @@ -8,7 +8,7 @@ namespace Mailtrap.IntegrationTests.TestExtensions; /// and its associated within tests. /// When disposed, it releases all resources created by the underlying service provider. /// -internal sealed class MailtrapClientScope(IMailtrapClient client, ServiceProvider provider) : IDisposable +internal sealed class MailtrapClientScope(IMailtrapClient client, IMailtrapOrganizationClient organizationClient, ServiceProvider provider) : IDisposable { /// /// Gets the configured Mailtrap client instance. @@ -18,6 +18,12 @@ internal sealed class MailtrapClientScope(IMailtrapClient client, ServiceProvide /// The instance resolved from the test service provider. /// public IMailtrapClient Client { get; } = client; + + /// + /// Gets the configured Mailtrap organization client instance. + /// + public IMailtrapOrganizationClient OrganizationClient { get; } = organizationClient; + private readonly ServiceProvider _provider = provider; /// @@ -247,8 +253,9 @@ private static MailtrapClientScope BuildClient(this MockHttpMessageHandler mockH var provider = serviceCollection.BuildServiceProvider(); var client = provider.GetRequiredService(); + var organizationClient = provider.GetRequiredService(); - return new MailtrapClientScope(client, provider); + return new MailtrapClientScope(client, organizationClient, provider); } /// diff --git a/tests/Mailtrap.UnitTests/ApiTokens/ApiTokenCollectionResourceTests.cs b/tests/Mailtrap.UnitTests/ApiTokens/ApiTokenCollectionResourceTests.cs new file mode 100644 index 00000000..4430609e --- /dev/null +++ b/tests/Mailtrap.UnitTests/ApiTokens/ApiTokenCollectionResourceTests.cs @@ -0,0 +1,53 @@ +namespace Mailtrap.UnitTests.ApiTokens; + + +[TestFixture] +internal sealed class ApiTokenCollectionResourceTests +{ + private readonly IRestResourceCommandFactory _commandFactoryMock = Mock.Of(); + private readonly Uri _resourceUri = EndpointsTestConstants.ApiDefaultUrl + .Append( + UrlSegmentsTestConstants.ApiRootSegment, + UrlSegmentsTestConstants.AccountsSegment) + .Append(TestContext.CurrentContext.Random.NextLong()) + .Append(UrlSegmentsTestConstants.ApiTokensSegment); + + + #region Constructor + + [Test] + public void Constructor_ShouldThrowArgumentNullException_WhenCommandFactoryIsNull() + { + // Act + var act = () => new ApiTokenCollectionResource(null!, _resourceUri); + + // Assert + act.Should().Throw(); + } + + [Test] + public void Constructor_ShouldThrowArgumentNullException_WhenUriIsNull() + { + // Act + var act = () => new ApiTokenCollectionResource(_commandFactoryMock, null!); + + // Assert + act.Should().Throw(); + } + + [Test] + public void ResourceUri_ShouldBeInitializedProperly() + { + // Arrange + var client = CreateResource(); + + // Assert + client.ResourceUri.Should().Be(_resourceUri); + } + + #endregion + + + + private ApiTokenCollectionResource CreateResource() => new(_commandFactoryMock, _resourceUri); +} diff --git a/tests/Mailtrap.UnitTests/ApiTokens/ApiTokenResourceTests.cs b/tests/Mailtrap.UnitTests/ApiTokens/ApiTokenResourceTests.cs new file mode 100644 index 00000000..4fdff334 --- /dev/null +++ b/tests/Mailtrap.UnitTests/ApiTokens/ApiTokenResourceTests.cs @@ -0,0 +1,54 @@ +namespace Mailtrap.UnitTests.ApiTokens; + + +[TestFixture] +internal sealed class ApiTokenResourceTests +{ + private readonly IRestResourceCommandFactory _commandFactoryMock = Mock.Of(); + private readonly Uri _resourceUri = EndpointsTestConstants.ApiDefaultUrl + .Append( + UrlSegmentsTestConstants.ApiRootSegment, + UrlSegmentsTestConstants.AccountsSegment) + .Append(TestContext.CurrentContext.Random.NextLong()) + .Append(UrlSegmentsTestConstants.ApiTokensSegment) + .Append(TestContext.CurrentContext.Random.NextLong()); + + + #region Constructor + + [Test] + public void Constructor_ShouldThrowArgumentNullException_WhenCommandFactoryIsNull() + { + // Act + var act = () => new ApiTokenResource(null!, _resourceUri); + + // Assert + act.Should().Throw(); + } + + [Test] + public void Constructor_ShouldThrowArgumentNullException_WhenUriIsNull() + { + // Act + var act = () => new ApiTokenResource(_commandFactoryMock, null!); + + // Assert + act.Should().Throw(); + } + + [Test] + public void ResourceUri_ShouldBeInitializedProperly() + { + // Arrange + var client = CreateResource(); + + // Assert + client.ResourceUri.Should().Be(_resourceUri); + } + + #endregion + + + + private ApiTokenResource CreateResource() => new(_commandFactoryMock, _resourceUri); +} diff --git a/tests/Mailtrap.UnitTests/ApiTokens/CreateApiTokenRequestValidatorTests.cs b/tests/Mailtrap.UnitTests/ApiTokens/CreateApiTokenRequestValidatorTests.cs new file mode 100644 index 00000000..4ed8909a --- /dev/null +++ b/tests/Mailtrap.UnitTests/ApiTokens/CreateApiTokenRequestValidatorTests.cs @@ -0,0 +1,50 @@ +namespace Mailtrap.UnitTests.ApiTokens; + + +[TestFixture] +internal sealed class CreateApiTokenRequestValidatorTests +{ + private static readonly CreateApiTokenRequestValidator s_validator = CreateApiTokenRequestValidator.Instance; + + + [Test] + public void Validate_WithValidName_ShouldPass() + { + var request = new CreateApiTokenRequest { Name = "My API Token" }; + + var result = s_validator.TestValidate(request); + + result.ShouldNotHaveAnyValidationErrors(); + } + + [Test] + public void Validate_WithEmptyName_ShouldFail() + { + var request = new CreateApiTokenRequest { Name = string.Empty }; + + var result = s_validator.TestValidate(request); + + result.ShouldHaveValidationErrorFor(r => r.Name); + } + + [Test] + public void Validate_WithNameLongerThan255_ShouldFail() + { + var request = new CreateApiTokenRequest { Name = new string('a', 256) }; + + var result = s_validator.TestValidate(request); + + result.ShouldHaveValidationErrorFor(r => r.Name); + } + + [Test] + public void Validate_WithValidResource_ShouldPass() + { + var request = new CreateApiTokenRequest { Name = "Token" }; + request.Resources.Add(new ApiTokenAccessRequest(ResourceType.Account, 3229, AccessLevel.Admin)); + + var result = s_validator.TestValidate(request); + + result.ShouldNotHaveAnyValidationErrors(); + } +} diff --git a/tests/Mailtrap.UnitTests/GlobalUsings.cs b/tests/Mailtrap.UnitTests/GlobalUsings.cs index 305b0223..10a9248e 100644 --- a/tests/Mailtrap.UnitTests/GlobalUsings.cs +++ b/tests/Mailtrap.UnitTests/GlobalUsings.cs @@ -11,6 +11,9 @@ global using Mailtrap.AccountAccesses.Models; global using Mailtrap.AccountAccesses.Requests; global using Mailtrap.Accounts; +global using Mailtrap.ApiTokens; +global using Mailtrap.ApiTokens.Requests; +global using Mailtrap.ApiTokens.Validators; global using Mailtrap.Attachments; global using Mailtrap.Billing; global using Mailtrap.Configuration; @@ -51,6 +54,10 @@ global using Mailtrap.EmailTemplates.Requests; global using Mailtrap.Inboxes; global using Mailtrap.Inboxes.Requests; +global using Mailtrap.Organizations; +global using Mailtrap.Organizations.Models; +global using Mailtrap.Organizations.Requests; +global using Mailtrap.Organizations.Validators; global using Mailtrap.Permissions; global using Mailtrap.Projects; global using Mailtrap.Projects.Requests; diff --git a/tests/Mailtrap.UnitTests/Organizations/CreateSubAccountRequestValidatorTests.cs b/tests/Mailtrap.UnitTests/Organizations/CreateSubAccountRequestValidatorTests.cs new file mode 100644 index 00000000..75041e49 --- /dev/null +++ b/tests/Mailtrap.UnitTests/Organizations/CreateSubAccountRequestValidatorTests.cs @@ -0,0 +1,39 @@ +namespace Mailtrap.UnitTests.Organizations; + + +[TestFixture] +internal sealed class CreateSubAccountRequestValidatorTests +{ + private static readonly CreateSubAccountRequestValidator s_validator = CreateSubAccountRequestValidator.Instance; + + + [Test] + public void Validate_WithValidName_ShouldPass() + { + var request = new CreateSubAccountRequest { Account = new SubAccountAttributes { Name = "Team A" } }; + + var result = s_validator.TestValidate(request); + + result.ShouldNotHaveAnyValidationErrors(); + } + + [Test] + public void Validate_WithEmptyName_ShouldFail() + { + var request = new CreateSubAccountRequest { Account = new SubAccountAttributes { Name = string.Empty } }; + + var result = s_validator.TestValidate(request); + + result.ShouldHaveValidationErrorFor(r => r.Account.Name); + } + + [Test] + public void Validate_WithNameLongerThan255_ShouldFail() + { + var request = new CreateSubAccountRequest { Account = new SubAccountAttributes { Name = new string('a', 256) } }; + + var result = s_validator.TestValidate(request); + + result.ShouldHaveValidationErrorFor(r => r.Account.Name); + } +} diff --git a/tests/Mailtrap.UnitTests/Organizations/OrganizationSubAccountCollectionResourceTests.cs b/tests/Mailtrap.UnitTests/Organizations/OrganizationSubAccountCollectionResourceTests.cs new file mode 100644 index 00000000..18f4bfb9 --- /dev/null +++ b/tests/Mailtrap.UnitTests/Organizations/OrganizationSubAccountCollectionResourceTests.cs @@ -0,0 +1,47 @@ +namespace Mailtrap.UnitTests.Organizations; + + +[TestFixture] +internal sealed class OrganizationSubAccountCollectionResourceTests +{ + private readonly IRestResourceCommandFactory _commandFactoryMock = Mock.Of(); + private readonly Uri _resourceUri = EndpointsTestConstants.ApiDefaultUrl + .Append( + UrlSegmentsTestConstants.ApiRootSegment, + UrlSegmentsTestConstants.OrganizationsSegment) + .Append(TestContext.CurrentContext.Random.NextLong()) + .Append(UrlSegmentsTestConstants.SubAccountsSegment); + + + #region Constructor + + [Test] + public void Constructor_ShouldThrowArgumentNullException_WhenCommandFactoryIsNull() + { + var act = () => new OrganizationSubAccountCollectionResource(null!, _resourceUri); + + act.Should().Throw(); + } + + [Test] + public void Constructor_ShouldThrowArgumentNullException_WhenUriIsNull() + { + var act = () => new OrganizationSubAccountCollectionResource(_commandFactoryMock, null!); + + act.Should().Throw(); + } + + [Test] + public void ResourceUri_ShouldBeInitializedProperly() + { + var client = CreateResource(); + + client.ResourceUri.Should().Be(_resourceUri); + } + + #endregion + + + + private OrganizationSubAccountCollectionResource CreateResource() => new(_commandFactoryMock, _resourceUri); +} diff --git a/tests/Mailtrap.UnitTests/TestConstants/UrlSegmentsTestConstants.cs b/tests/Mailtrap.UnitTests/TestConstants/UrlSegmentsTestConstants.cs index eaf9e57a..a234e60c 100644 --- a/tests/Mailtrap.UnitTests/TestConstants/UrlSegmentsTestConstants.cs +++ b/tests/Mailtrap.UnitTests/TestConstants/UrlSegmentsTestConstants.cs @@ -25,4 +25,7 @@ internal static class UrlSegmentsTestConstants internal static string BatchEmailSegment { get; } = "batch"; internal static string StatsSegment { get; } = "stats"; internal static string EmailLogsSegment { get; } = "email_logs"; + internal static string ApiTokensSegment { get; } = "api_tokens"; + internal static string OrganizationsSegment { get; } = "organizations"; + internal static string SubAccountsSegment { get; } = "sub_accounts"; }