diff --git a/Mailtrap.sln b/Mailtrap.sln
index 8becfbb5..0631c09c 100644
--- a/Mailtrap.sln
+++ b/Mailtrap.sln
@@ -87,6 +87,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Mailtrap.Example.TestingMes
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Mailtrap.Example.Factory", "examples\Mailtrap.Example.Factory\Mailtrap.Example.Factory.csproj", "{AB1237F4-D074-4D3C-9AE4-6794BD30EA71}"
EndProject
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Mailtrap.Example.Contact", "examples\Mailtrap.Example.Contact\Mailtrap.Example.Contact.csproj", "{3F8D2B21-5C6E-4A9A-9C3B-9F1D2A7B8C64}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -161,6 +163,10 @@ Global
{AB1237F4-D074-4D3C-9AE4-6794BD30EA71}.Debug|Any CPU.Build.0 = Debug|Any CPU
{AB1237F4-D074-4D3C-9AE4-6794BD30EA71}.Release|Any CPU.ActiveCfg = Release|Any CPU
{AB1237F4-D074-4D3C-9AE4-6794BD30EA71}.Release|Any CPU.Build.0 = Release|Any CPU
+ {3F8D2B21-5C6E-4A9A-9C3B-9F1D2A7B8C64}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {3F8D2B21-5C6E-4A9A-9C3B-9F1D2A7B8C64}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {3F8D2B21-5C6E-4A9A-9C3B-9F1D2A7B8C64}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {3F8D2B21-5C6E-4A9A-9C3B-9F1D2A7B8C64}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -183,6 +189,7 @@ Global
{AB9CF980-EAC5-4BC4-AC85-FFA0770FF7DA} = {09E18837-1DDE-4EAF-80EC-DA55557C81EB}
{F6357CAB-06C6-4603-99E7-1EDB79ACA8E8} = {09E18837-1DDE-4EAF-80EC-DA55557C81EB}
{AB1237F4-D074-4D3C-9AE4-6794BD30EA71} = {09E18837-1DDE-4EAF-80EC-DA55557C81EB}
+ {3F8D2B21-5C6E-4A9A-9C3B-9F1D2A7B8C64} = {09E18837-1DDE-4EAF-80EC-DA55557C81EB}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {0FF614CC-FEBC-4C66-B3FC-FCB73EE511D7}
diff --git a/examples/Mailtrap.Example.Contact/Mailtrap.Example.Contact.csproj b/examples/Mailtrap.Example.Contact/Mailtrap.Example.Contact.csproj
new file mode 100644
index 00000000..6f9ac7de
--- /dev/null
+++ b/examples/Mailtrap.Example.Contact/Mailtrap.Example.Contact.csproj
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+
+ PreserveNewest
+
+
+
diff --git a/examples/Mailtrap.Example.Contact/Program.cs b/examples/Mailtrap.Example.Contact/Program.cs
new file mode 100644
index 00000000..6cec63f5
--- /dev/null
+++ b/examples/Mailtrap.Example.Contact/Program.cs
@@ -0,0 +1,81 @@
+using Mailtrap;
+using Mailtrap.Accounts;
+using Mailtrap.Contacts;
+using Mailtrap.Contacts.Models;
+using Mailtrap.Contacts.Requests;
+using Mailtrap.Contacts.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);
+
+ var contactEmail = "example@mailtrap.io";
+
+ // Get resource for contacts collection
+ IContactCollectionResource contactsResource = accountResource.Contacts();
+
+ // Get all contacts for account
+ IList contacts = await contactsResource.GetAll();
+
+ Contact? contact = contacts
+ .FirstOrDefault(p => string.Equals(p.Email, contactEmail, StringComparison.OrdinalIgnoreCase));
+
+
+ if (contact is null)
+ {
+ logger.LogWarning("No contact found. Creating.");
+
+ // Create contact
+ var createContactRequest = new CreateContactRequest(contactEmail);
+ createContactRequest.Fields.Add("first_name", "John");
+ createContactRequest.Fields.Add("last_name", "Doe");
+ CreateContactResponse createContactResponse = await contactsResource.Create(createContactRequest);
+ contact = createContactResponse.Contact;
+ }
+ else
+ {
+ logger.LogInformation("Contact found.");
+ }
+
+ // Get resource for specific contact
+ IContactResource contactResource = accountResource.Contact(contact.Id);
+
+ // Get details
+ ContactResponse contactResponse = await contactResource.GetDetails();
+ Contact contactDetails = contactResponse.Contact;
+ logger.LogInformation("Contact: {Contact}", contactDetails);
+
+ // Update contact details
+ var updateContactRequest = new UpdateContactRequest("test@mailtrap.io");
+ UpdateContactResponse updateContactResponse = await contactResource.Update(updateContactRequest);
+ Contact updatedContact = updateContactResponse.Contact;
+ logger.LogInformation("Updated Contact: {Contact}", updatedContact);
+
+ // Delete contact
+ // Beware that contact resource becomes invalid after deletion and should not be used anymore
+ await contactResource.Delete();
+ logger.LogInformation("Contact Deleted.");
+}
+catch (Exception ex)
+{
+ logger.LogError(ex, "An error occurred during API call.");
+ Environment.FailFast(ex.Message);
+ throw;
+}
diff --git a/examples/Mailtrap.Example.Contact/Properties/launchSettings.json b/examples/Mailtrap.Example.Contact/Properties/launchSettings.json
new file mode 100644
index 00000000..b8daf491
--- /dev/null
+++ b/examples/Mailtrap.Example.Contact/Properties/launchSettings.json
@@ -0,0 +1,10 @@
+{
+ "profiles": {
+ "Project": {
+ "commandName": "Project",
+ "environmentVariables": {
+ "DOTNET_ENVIRONMENT": "Development"
+ }
+ }
+ }
+}
diff --git a/examples/Mailtrap.Example.Contact/appsettings.json b/examples/Mailtrap.Example.Contact/appsettings.json
new file mode 100644
index 00000000..7cf4089d
--- /dev/null
+++ b/examples/Mailtrap.Example.Contact/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 0f04458e..f9596393 100644
--- a/src/Mailtrap.Abstractions/Accounts/IAccountResource.cs
+++ b/src/Mailtrap.Abstractions/Accounts/IAccountResource.cs
@@ -9,7 +9,7 @@ public interface IAccountResource : IRestResource
///
/// Gets account access collection resource for the account, represented by this resource instance.
///
- ///
+ ///
///
/// Account access collection resource for the account, represented by this resource instance.
///
@@ -22,7 +22,7 @@ public interface IAccountResource : IRestResource
///
/// ID of account access to get resource for.
///
- ///
+ ///
///
/// Resource for the account access with specified ID.
///
@@ -36,7 +36,7 @@ public interface IAccountResource : IRestResource
///
/// Gets permissions resource for the account, represented by this resource instance.
///
- ///
+ ///
///
/// Permissions resource for the account, represented by this resource instance.
///
@@ -46,7 +46,7 @@ public interface IAccountResource : IRestResource
///
/// Gets billing resource for the account, represented by this resource instance.
///
- ///
+ ///
///
/// Billing resource for the account, represented by this resource instance.
///
@@ -56,7 +56,7 @@ public interface IAccountResource : IRestResource
///
/// Gets sending domain collection resource for the account, represented by this resource instance.
///
- ///
+ ///
///
/// Sending domain collection resource for the account, represented by this resource instance.
///
@@ -69,7 +69,7 @@ public interface IAccountResource : IRestResource
///
/// ID of sending domain to get resource for.
///
- ///
+ ///
///
/// Resource for the sending domain with specified ID.
///
@@ -83,7 +83,7 @@ public interface IAccountResource : IRestResource
///
/// Gets project collection resource for the account, represented by this resource instance.
///
- ///
+ ///
///
/// Project collection resource for the account, represented by this resource instance.
///
@@ -96,7 +96,7 @@ public interface IAccountResource : IRestResource
///
/// ID of project to get resource for.
///
- ///
+ ///
///
/// Resource for the project with specified ID.
///
@@ -110,7 +110,7 @@ public interface IAccountResource : IRestResource
///
/// Gets inbox collection resource for the account, represented by this resource instance.
///
- ///
+ ///
///
/// Inbox collection resource for the account, represented by this resource instance.
///
@@ -123,7 +123,7 @@ public interface IAccountResource : IRestResource
///
/// ID of inbox to get resource for.
///
- ///
+ ///
///
/// Resource for the inbox with specified ID.
///
@@ -132,4 +132,30 @@ public interface IAccountResource : IRestResource
/// When is less than or equal to zero.
///
public IInboxResource Inbox(long inboxId);
+
+ ///
+ /// Gets contact collection resource for the account, represented by this resource instance.
+ ///
+ ///
+ ///
+ /// Contact collection resource for the account, represented by this resource instance.
+ ///
+ public IContactCollectionResource Contacts();
+
+ ///
+ /// Gets resource for specific contact, identified by .
+ ///
+ ///
+ ///
+ /// ID or email of contact to get resource for.
+ ///
+ ///
+ ///
+ /// Resource for the contact with specified ID or email.
+ ///
+ ///
+ ///
+ /// When is null or empty.
+ ///
+ public IContactResource Contact(string idOrEmail);
}
diff --git a/src/Mailtrap.Abstractions/Contacts/Converters/DateTimeToUnixMsJsonConverter.cs b/src/Mailtrap.Abstractions/Contacts/Converters/DateTimeToUnixMsJsonConverter.cs
new file mode 100644
index 00000000..560a6246
--- /dev/null
+++ b/src/Mailtrap.Abstractions/Contacts/Converters/DateTimeToUnixMsJsonConverter.cs
@@ -0,0 +1,34 @@
+namespace Mailtrap.Contacts.Converters;
+
+///
+/// Converts DateTimeOffset to Unix time milliseconds for JSON serialization.
+///
+internal sealed class DateTimeToUnixMsNullableJsonConverter : JsonConverter
+{
+ public override DateTimeOffset? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
+ {
+ if (reader.TokenType == JsonTokenType.Null)
+ {
+ return null;
+ }
+
+ if (reader.TokenType != JsonTokenType.Number)
+ {
+ throw new JsonException($"Expected number for Unix time milliseconds but got {reader.TokenType}.");
+ }
+
+ var ms = reader.GetInt64();
+ return DateTimeOffset.FromUnixTimeMilliseconds(ms);
+ }
+
+ public override void Write(Utf8JsonWriter writer, DateTimeOffset? value, JsonSerializerOptions options)
+ {
+ if (value is null)
+ {
+ writer.WriteNullValue();
+ return;
+ }
+
+ writer.WriteNumberValue(value.Value.ToUnixTimeMilliseconds());
+ }
+}
diff --git a/src/Mailtrap.Abstractions/Contacts/IContactCollectionResource.cs b/src/Mailtrap.Abstractions/Contacts/IContactCollectionResource.cs
new file mode 100644
index 00000000..44ff8ea5
--- /dev/null
+++ b/src/Mailtrap.Abstractions/Contacts/IContactCollectionResource.cs
@@ -0,0 +1,37 @@
+namespace Mailtrap.Contacts;
+
+///
+/// Represents Contacts collection resource..
+///
+public interface IContactCollectionResource : IRestResource
+{
+ ///
+ /// Gets contacts.
+ ///
+ ///
+ ///
+ /// Token to control operation cancellation.
+ ///
+ ///
+ ///
+ /// Collection of contact details.
+ ///
+ public Task> GetAll(CancellationToken cancellationToken = default);
+
+ ///
+ /// Creates a new contact with details specified by .
+ ///
+ ///
+ ///
+ /// Request containing contact details for creation.
+ ///
+ ///
+ ///
+ ///
+ ///
+ ///
+ ///
+ /// Created contact details.
+ ///
+ public Task Create(CreateContactRequest request, CancellationToken cancellationToken = default);
+}
diff --git a/src/Mailtrap.Abstractions/Contacts/IContactResource.cs b/src/Mailtrap.Abstractions/Contacts/IContactResource.cs
new file mode 100644
index 00000000..1fe6fa39
--- /dev/null
+++ b/src/Mailtrap.Abstractions/Contacts/IContactResource.cs
@@ -0,0 +1,57 @@
+namespace Mailtrap.Contacts;
+
+///
+/// Represents Contacts resource.
+///
+public interface IContactResource : IRestResource
+{
+ ///
+ /// Gets details of the contact, represented by the current resource instance.
+ ///
+ ///
+ ///
+ /// Token to control operation cancellation.
+ ///
+ ///
+ ///
+ /// Requested contact details.
+ ///
+ public Task GetDetails(CancellationToken cancellationToken = default);
+
+ ///
+ /// Updates the contact, represented by the current resource instance, with details specified by .
+ ///
+ ///
+ ///
+ /// Contact details for update.
+ ///
+ ///
+ ///
+ ///
+ ///
+ ///
+ ///
+ /// Updated contact details.
+ ///
+ public Task Update(UpdateContactRequest request, CancellationToken cancellationToken = default);
+
+ ///
+ /// Deletes a contact, represented by the current resource instance.
+ ///
+ ///
+ ///
+ ///
+ ///
+ ///
+ ///
+ /// Nothing is returned upon successful deletion.
+ ///
+ ///
+ ///
+ ///
+ /// After deletion of the contact, represented by the current resource instance, it will be no longer available.
+ /// Thus any further operations on it will result in an error.
+ ///
+ ///
+ public Task Delete(CancellationToken cancellationToken = default);
+}
diff --git a/src/Mailtrap.Abstractions/Contacts/Models/Contact.cs b/src/Mailtrap.Abstractions/Contacts/Models/Contact.cs
new file mode 100644
index 00000000..d3989abb
--- /dev/null
+++ b/src/Mailtrap.Abstractions/Contacts/Models/Contact.cs
@@ -0,0 +1,81 @@
+namespace Mailtrap.Contacts.Models;
+
+///
+/// Represents Contact details.
+///
+public sealed record Contact
+{
+ ///
+ /// Gets Contact identifier.
+ ///
+ ///
+ ///
+ /// Contact identifier.
+ ///
+ [JsonPropertyName("id")]
+ public string Id { get; set; } = string.Empty;
+
+ ///
+ /// Gets Contact email.
+ ///
+ ///
+ ///
+ /// Contact email.
+ ///
+ [JsonPropertyName("email")]
+ public string Email { get; set; } = string.Empty;
+
+ ///
+ /// Gets Contact fields.
+ ///
+ ///
+ ///
+ /// Contact fields.
+ ///
+ [JsonPropertyName("fields")]
+ [JsonObjectCreationHandling(JsonObjectCreationHandling.Populate)]
+ public IDictionary Fields { get; } = new Dictionary();
+
+ ///
+ /// Gets Contact's list ids.
+ ///
+ ///
+ ///
+ /// Contact's list ids.
+ ///
+ [JsonPropertyName("list_ids")]
+ [JsonObjectCreationHandling(JsonObjectCreationHandling.Populate)]
+ public IList ListIds { get; } = [];
+
+ ///
+ /// Gets status of the contact.
+ ///
+ ///
+ ///
+ /// Contact's status.
+ ///
+ [JsonPropertyName("status")]
+ public ContactStatus Status { get; set; } = ContactStatus.Unknown;
+
+ ///
+ /// Gets Contact creation date and time.
+ ///
+ ///
+ ///
+ /// Contact creation date and time.
+ ///
+ [JsonPropertyName("created_at")]
+ [JsonConverter(typeof(DateTimeToUnixMsNullableJsonConverter))]
+ public DateTimeOffset? CreatedAt { get; set; }
+
+ ///
+ /// Gets Contact's update date and time.
+ ///
+ ///
+ ///
+ /// Contact's update date and time.
+ ///
+ [JsonPropertyName("updated_at")]
+ [JsonConverter(typeof(DateTimeToUnixMsNullableJsonConverter))]
+ public DateTimeOffset? UpdatedAt { get; set; }
+}
diff --git a/src/Mailtrap.Abstractions/Contacts/Models/ContactAction.cs b/src/Mailtrap.Abstractions/Contacts/Models/ContactAction.cs
new file mode 100644
index 00000000..e7636321
--- /dev/null
+++ b/src/Mailtrap.Abstractions/Contacts/Models/ContactAction.cs
@@ -0,0 +1,25 @@
+namespace Mailtrap.Contacts.Models;
+
+///
+/// Contact update actions.
+///
+public sealed record ContactAction : StringEnum
+{
+ ///
+ /// Gets the value representing "Updated" contact action.
+ ///
+ ///
+ ///
+ /// Represents "Updated" contact action.
+ ///
+ public static readonly ContactAction Updated = Define("updated");
+
+ ///
+ /// Gets the value representing "Created" contact action.
+ ///
+ ///
+ ///
+ /// Represents "Created" contact action.
+ ///
+ public static readonly ContactAction Created = Define("created");
+}
diff --git a/src/Mailtrap.Abstractions/Contacts/Models/ContactStatus.cs b/src/Mailtrap.Abstractions/Contacts/Models/ContactStatus.cs
new file mode 100644
index 00000000..16a3d316
--- /dev/null
+++ b/src/Mailtrap.Abstractions/Contacts/Models/ContactStatus.cs
@@ -0,0 +1,25 @@
+namespace Mailtrap.Contacts.Models;
+
+///
+/// Contact subscription status.
+///
+public sealed record ContactStatus : StringEnum
+{
+ ///
+ /// Gets the value representing "Subscribed" contact status.
+ ///
+ ///
+ ///
+ /// Represents "Subscribed" contact status.
+ ///
+ public static readonly ContactStatus Subscribed = Define("subscribed");
+
+ ///
+ /// Gets the value representing "Unsubscribed" contact status.
+ ///
+ ///
+ ///
+ /// Represents "Unsubscribed" contact status.
+ ///
+ public static readonly ContactStatus Unsubscribed = Define("unsubscribed");
+}
diff --git a/src/Mailtrap.Abstractions/Contacts/Requests/ContactRequest.cs b/src/Mailtrap.Abstractions/Contacts/Requests/ContactRequest.cs
new file mode 100644
index 00000000..97f64c68
--- /dev/null
+++ b/src/Mailtrap.Abstractions/Contacts/Requests/ContactRequest.cs
@@ -0,0 +1,59 @@
+namespace Mailtrap.Contacts.Requests;
+
+///
+/// Generic request object for contact CRUD operations.
+///
+public record ContactRequest : IValidatable
+{
+ ///
+ /// Gets contact email.
+ ///
+ ///
+ ///
+ /// Contact email.
+ ///
+ [JsonPropertyName("email")]
+ [JsonRequired]
+ public string Email { get; set; }
+
+ ///
+ /// Gets contact fields.
+ ///
+ ///
+ ///
+ /// Contact fields.
+ ///
+ [JsonPropertyName("fields")]
+ public IDictionary Fields { get; } = new Dictionary();
+
+ ///
+ /// Primary instance constructor.
+ ///
+ ///
+ ///
+ /// Email of the contact.
+ ///
+ ///
+ ///
+ /// Contact's must be min 2 characters and max 100 characters long.
+ ///
+ ///
+ ///
+ /// When is or .
+ ///
+ public ContactRequest(string email)
+ {
+ Ensure.NotNullOrEmpty(email, nameof(email));
+
+ Email = email;
+ }
+
+
+ ///
+ public ValidationResult Validate()
+ {
+ return ContactRequestValidator.Instance
+ .Validate(this)
+ .ToMailtrapValidationResult();
+ }
+}
diff --git a/src/Mailtrap.Abstractions/Contacts/Requests/CreateContactRequest.cs b/src/Mailtrap.Abstractions/Contacts/Requests/CreateContactRequest.cs
new file mode 100644
index 00000000..4744aa91
--- /dev/null
+++ b/src/Mailtrap.Abstractions/Contacts/Requests/CreateContactRequest.cs
@@ -0,0 +1,20 @@
+namespace Mailtrap.Contacts.Requests;
+
+///
+/// Request object for creating a contact.
+///
+public sealed record CreateContactRequest : ContactRequest
+{
+ ///
+ /// Gets contact list IDs.
+ ///
+ ///
+ ///
+ /// Contact list IDs.
+ ///
+ [JsonPropertyName("list_ids")]
+ public IList ListIds { get; } = [];
+
+ ///
+ public CreateContactRequest(string email) : base(email) { }
+}
diff --git a/src/Mailtrap.Abstractions/Contacts/Requests/UpdateContactRequest.cs b/src/Mailtrap.Abstractions/Contacts/Requests/UpdateContactRequest.cs
new file mode 100644
index 00000000..06cfc4bb
--- /dev/null
+++ b/src/Mailtrap.Abstractions/Contacts/Requests/UpdateContactRequest.cs
@@ -0,0 +1,40 @@
+namespace Mailtrap.Contacts.Requests;
+
+///
+/// Request object for updating a contact.
+///
+public sealed record UpdateContactRequest : ContactRequest
+{
+ ///
+ /// Gets contact list IDs to include.
+ ///
+ ///
+ ///
+ /// Contact list IDs to include.
+ ///
+ [JsonPropertyName("list_ids_included")]
+ public IList ListIdsIncluded { get; } = [];
+
+ ///
+ /// Gets contact list IDs to exclude.
+ ///
+ ///
+ ///
+ /// Contact list IDs to exclude.
+ ///
+ [JsonPropertyName("list_ids_excluded")]
+ public IList ListIdsExcluded { get; } = [];
+
+ ///
+ /// Gets contact "unsubscribed" status.
+ ///
+ ///
+ ///
+ /// Contact "unsubscribed" status.
+ ///
+ [JsonPropertyName("unsubscribed")]
+ public bool? Unsubscribed { get; set; }
+
+ ///
+ public UpdateContactRequest(string email) : base(email) { }
+}
diff --git a/src/Mailtrap.Abstractions/Contacts/Responses/ContactResponse.cs b/src/Mailtrap.Abstractions/Contacts/Responses/ContactResponse.cs
new file mode 100644
index 00000000..317ead04
--- /dev/null
+++ b/src/Mailtrap.Abstractions/Contacts/Responses/ContactResponse.cs
@@ -0,0 +1,18 @@
+namespace Mailtrap.Contacts.Responses;
+
+///
+/// Generic response object for contact operations.
+///
+public record ContactResponse
+{
+ ///
+ /// Gets created contact data.
+ ///
+ ///
+ ///
+ /// Contact data.
+ ///
+ [JsonPropertyName("data")]
+ [JsonObjectCreationHandling(JsonObjectCreationHandling.Populate)]
+ public Contact Contact { get; set; } = new();
+}
diff --git a/src/Mailtrap.Abstractions/Contacts/Responses/CreateContactResponse.cs b/src/Mailtrap.Abstractions/Contacts/Responses/CreateContactResponse.cs
new file mode 100644
index 00000000..e912e072
--- /dev/null
+++ b/src/Mailtrap.Abstractions/Contacts/Responses/CreateContactResponse.cs
@@ -0,0 +1,8 @@
+namespace Mailtrap.Contacts.Responses;
+
+///
+/// Response object for contact creation.
+///
+public sealed record CreateContactResponse : ContactResponse
+{
+}
diff --git a/src/Mailtrap.Abstractions/Contacts/Responses/UpdateContactResponse.cs b/src/Mailtrap.Abstractions/Contacts/Responses/UpdateContactResponse.cs
new file mode 100644
index 00000000..af8b6047
--- /dev/null
+++ b/src/Mailtrap.Abstractions/Contacts/Responses/UpdateContactResponse.cs
@@ -0,0 +1,18 @@
+namespace Mailtrap.Contacts.Responses;
+
+///
+/// Response object for contact update.
+///
+public sealed record UpdateContactResponse : ContactResponse
+{
+ ///
+ /// Gets the action performed on the contact.
+ ///
+ ///
+ ///
+ /// "created" if contact does not exist,
+ /// "updated" if contact already exists.
+ ///
+ [JsonPropertyName("action")]
+ public ContactAction Action { get; set; } = ContactAction.Unknown;
+}
diff --git a/src/Mailtrap.Abstractions/Contacts/Validators/ContactRequestValidator.cs b/src/Mailtrap.Abstractions/Contacts/Validators/ContactRequestValidator.cs
new file mode 100644
index 00000000..6d44a6cb
--- /dev/null
+++ b/src/Mailtrap.Abstractions/Contacts/Validators/ContactRequestValidator.cs
@@ -0,0 +1,22 @@
+namespace Mailtrap.Contacts.Validators;
+
+
+///
+/// Validator for Create/Update contact requests.
+/// Ensures contact's email is not empty and length is within the allowed range.
+///
+public sealed class ContactRequestValidator : AbstractValidator
+{
+ ///
+ /// Static validator instance for reuse.
+ ///
+ public static ContactRequestValidator Instance { get; } = new();
+
+ ///
+ /// Primary constructor.
+ ///
+ public ContactRequestValidator()
+ {
+ RuleFor(r => r.Email).NotEmpty().Length(2, 100);
+ }
+}
diff --git a/src/Mailtrap.Abstractions/GlobalUsings.cs b/src/Mailtrap.Abstractions/GlobalUsings.cs
index 09d1b175..28d0de4e 100644
--- a/src/Mailtrap.Abstractions/GlobalUsings.cs
+++ b/src/Mailtrap.Abstractions/GlobalUsings.cs
@@ -21,6 +21,12 @@
global using Mailtrap.Core.Models;
global using Mailtrap.Core.Rest;
global using Mailtrap.Core.Validation;
+global using Mailtrap.Contacts;
+global using Mailtrap.Contacts.Models;
+global using Mailtrap.Contacts.Requests;
+global using Mailtrap.Contacts.Responses;
+global using Mailtrap.Contacts.Converters;
+global using Mailtrap.Contacts.Validators;
global using Mailtrap.Emails;
global using Mailtrap.Emails.Models;
global using Mailtrap.Emails.Requests;
diff --git a/src/Mailtrap/Accounts/AccountResource.cs b/src/Mailtrap/Accounts/AccountResource.cs
index 394f0c31..22c56e85 100644
--- a/src/Mailtrap/Accounts/AccountResource.cs
+++ b/src/Mailtrap/Accounts/AccountResource.cs
@@ -52,4 +52,19 @@ public IInboxCollectionResource Inboxes()
public IInboxResource Inbox(long inboxId)
=> new InboxResource(RestResourceCommandFactory, ResourceUri.Append(UrlSegments.InboxesSegment).Append(inboxId));
+
+ public IContactCollectionResource Contacts()
+ => new ContactCollectionResource(RestResourceCommandFactory, ResourceUri.Append(UrlSegments.ContactsSegment));
+
+ public IContactResource Contact(string idOrEmail)
+ {
+ Ensure.NotNullOrEmpty(idOrEmail, nameof(idOrEmail));
+ var encoded = Uri.EscapeDataString(idOrEmail);
+
+ return new ContactResource(
+ RestResourceCommandFactory,
+ ResourceUri
+ .Append(UrlSegments.ContactsSegment)
+ .Append(encoded));
+ }
}
diff --git a/src/Mailtrap/Contacts/ContactCollectionResource.cs b/src/Mailtrap/Contacts/ContactCollectionResource.cs
new file mode 100644
index 00000000..3e28a867
--- /dev/null
+++ b/src/Mailtrap/Contacts/ContactCollectionResource.cs
@@ -0,0 +1,19 @@
+using Mailtrap.Contacts.Responses;
+
+namespace Mailtrap.Contacts;
+
+///
+/// Implementation of Contact Collection resource.
+///
+internal sealed class ContactCollectionResource : RestResource, IContactCollectionResource
+{
+ public ContactCollectionResource(IRestResourceCommandFactory restResourceCommandFactory, Uri resourceUri)
+ : base(restResourceCommandFactory, resourceUri) { }
+
+
+ public async Task> GetAll(CancellationToken cancellationToken = default)
+ => await GetList(cancellationToken).ConfigureAwait(false);
+
+ public async Task Create(CreateContactRequest request, CancellationToken cancellationToken = default)
+ => await Create(request.ToDto(), cancellationToken).ConfigureAwait(false);
+}
diff --git a/src/Mailtrap/Contacts/ContactResource.cs b/src/Mailtrap/Contacts/ContactResource.cs
new file mode 100644
index 00000000..6fd06a15
--- /dev/null
+++ b/src/Mailtrap/Contacts/ContactResource.cs
@@ -0,0 +1,18 @@
+using Mailtrap.Contacts.Responses;
+
+namespace Mailtrap.Contacts;
+
+///
+/// Implementation of Contacts API operations.
+///
+internal sealed class ContactResource : RestResource, IContactResource
+{
+ public ContactResource(IRestResourceCommandFactory restResourceCommandFactory, Uri resourceUri)
+ : base(restResourceCommandFactory, resourceUri) { }
+ public async Task GetDetails(CancellationToken cancellationToken = default)
+ => await Get(cancellationToken).ConfigureAwait(false);
+ public async Task Update(UpdateContactRequest request, CancellationToken cancellationToken = default)
+ => await Update(request.ToDto(), cancellationToken).ConfigureAwait(false);
+ public async Task Delete(CancellationToken cancellationToken = default)
+ => await DeleteWithStatusCodeResult(cancellationToken).ConfigureAwait(false);
+}
diff --git a/src/Mailtrap/Contacts/Requests/ContactRequestDto.cs b/src/Mailtrap/Contacts/Requests/ContactRequestDto.cs
new file mode 100644
index 00000000..3141b778
--- /dev/null
+++ b/src/Mailtrap/Contacts/Requests/ContactRequestDto.cs
@@ -0,0 +1,37 @@
+namespace Mailtrap.Contacts.Requests;
+
+
+///
+/// Generic request object for contact CRUD operations.
+///
+internal record ContactRequestDto : IValidatable
+ where T : ContactRequest
+{
+ ///
+ /// Gets or sets contact request payload.
+ ///
+ ///
+ ///
+ /// Contact request payload.
+ ///
+ [JsonPropertyName("contact")]
+ [JsonPropertyOrder(1)]
+ public T Contact { get; }
+
+
+ public ContactRequestDto(T contact)
+ {
+ Ensure.NotNull(contact, nameof(contact));
+
+ Contact = contact;
+ }
+
+
+ ///
+ public ValidationResult Validate()
+ {
+ return ContactRequestValidator.Instance
+ .Validate(Contact)
+ .ToMailtrapValidationResult();
+ }
+}
diff --git a/src/Mailtrap/Contacts/Requests/ContactRequestExtensions.cs b/src/Mailtrap/Contacts/Requests/ContactRequestExtensions.cs
new file mode 100644
index 00000000..a076125d
--- /dev/null
+++ b/src/Mailtrap/Contacts/Requests/ContactRequestExtensions.cs
@@ -0,0 +1,9 @@
+namespace Mailtrap.Contacts.Requests;
+
+
+internal static class ContactRequestExtensions
+{
+ public static CreateContactRequestDto ToDto(this CreateContactRequest request) => new(request);
+
+ public static UpdateContactRequestDto ToDto(this UpdateContactRequest request) => new(request);
+}
diff --git a/src/Mailtrap/Contacts/Requests/CreateContactRequestDto.cs b/src/Mailtrap/Contacts/Requests/CreateContactRequestDto.cs
new file mode 100644
index 00000000..01c63c40
--- /dev/null
+++ b/src/Mailtrap/Contacts/Requests/CreateContactRequestDto.cs
@@ -0,0 +1,10 @@
+namespace Mailtrap.Contacts.Requests;
+
+
+///
+/// Request object for creating contact.
+///
+internal sealed record CreateContactRequestDto : ContactRequestDto
+{
+ public CreateContactRequestDto(CreateContactRequest request) : base(request) { }
+}
diff --git a/src/Mailtrap/Contacts/Requests/UpdateContactRequestDto.cs b/src/Mailtrap/Contacts/Requests/UpdateContactRequestDto.cs
new file mode 100644
index 00000000..0dcf24c3
--- /dev/null
+++ b/src/Mailtrap/Contacts/Requests/UpdateContactRequestDto.cs
@@ -0,0 +1,10 @@
+namespace Mailtrap.Contacts.Requests;
+
+
+///
+/// Request object for updating contact details.
+///
+internal sealed record UpdateContactRequestDto : ContactRequestDto
+{
+ public UpdateContactRequestDto(UpdateContactRequest request) : base(request) { }
+}
diff --git a/src/Mailtrap/Core/Constants/UrlSegments.cs b/src/Mailtrap/Core/Constants/UrlSegments.cs
index c0e2b3fd..1c4520ad 100644
--- a/src/Mailtrap/Core/Constants/UrlSegments.cs
+++ b/src/Mailtrap/Core/Constants/UrlSegments.cs
@@ -6,4 +6,5 @@ internal static class UrlSegments
internal static string ApiRootSegment { get; } = "api";
internal static string ProjectsSegment { get; } = "projects";
internal static string InboxesSegment { get; } = "inboxes";
+ internal static string ContactsSegment { get; } = "contacts";
}
diff --git a/src/Mailtrap/Core/Rest/Commands/DeleteWithStatusCodeResultRestResourceCommand.cs b/src/Mailtrap/Core/Rest/Commands/DeleteWithStatusCodeResultRestResourceCommand.cs
index 51cfa789..0c0e944b 100644
--- a/src/Mailtrap/Core/Rest/Commands/DeleteWithStatusCodeResultRestResourceCommand.cs
+++ b/src/Mailtrap/Core/Rest/Commands/DeleteWithStatusCodeResultRestResourceCommand.cs
@@ -1,6 +1,6 @@
namespace Mailtrap.Core.Rest.Commands;
-internal sealed class DeleteWithStatusCodeResultRestResourceCommand : DeleteRestResourceCommand
+internal sealed class DeleteWithStatusCodeResultRestResourceCommand : DeleteRestResourceCommand
{
public DeleteWithStatusCodeResultRestResourceCommand(
IHttpClientProvider httpClientProvider,
diff --git a/src/Mailtrap/Core/Rest/IRestResourceCommandFactory.cs b/src/Mailtrap/Core/Rest/IRestResourceCommandFactory.cs
index 38609f9a..ec544c94 100644
--- a/src/Mailtrap/Core/Rest/IRestResourceCommandFactory.cs
+++ b/src/Mailtrap/Core/Rest/IRestResourceCommandFactory.cs
@@ -7,10 +7,8 @@ internal interface IRestResourceCommandFactory
public IRestResourceCommand CreatePlainText(Uri resourceUri, params string[] additionalAcceptContentTypes);
public IRestResourceCommand CreatePatch(Uri resourceUri);
public IRestResourceCommand CreateDelete(Uri resourceUri);
- public IRestResourceCommand CreateDeleteWithStatusCodeResult(Uri resourceUri);
-
+ 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 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/RestResource.cs b/src/Mailtrap/Core/Rest/RestResource.cs
index 4e7046f0..1d2a1f5b 100644
--- a/src/Mailtrap/Core/Rest/RestResource.cs
+++ b/src/Mailtrap/Core/Rest/RestResource.cs
@@ -61,6 +61,12 @@ protected async Task Delete(CancellationToken cancellationToke
.Execute(cancellationToken)
.ConfigureAwait(false);
+ protected async Task DeleteWithStatusCodeResult(CancellationToken cancellationToken = default)
+ => await RestResourceCommandFactory
+ .CreateDeleteWithStatusCodeResult(ResourceUri)
+ .Execute(cancellationToken)
+ .ConfigureAwait(false);
+
private Task Get(Uri uri, CancellationToken cancellationToken = default)
=> RestResourceCommandFactory
diff --git a/src/Mailtrap/Core/Rest/RestResourceCommandFactory.cs b/src/Mailtrap/Core/Rest/RestResourceCommandFactory.cs
index 9ce386ac..373c2a2b 100644
--- a/src/Mailtrap/Core/Rest/RestResourceCommandFactory.cs
+++ b/src/Mailtrap/Core/Rest/RestResourceCommandFactory.cs
@@ -53,11 +53,12 @@ public IRestResourceCommand CreateDelete(Uri resourceUri)
_httpResponseHandlerFactory,
resourceUri);
- public IRestResourceCommand CreateDeleteWithStatusCodeResult(Uri resourceUri) => new DeleteWithStatusCodeResultRestResourceCommand(
- _httpClientProvider,
- _httpRequestMessageFactory,
- _httpResponseHandlerFactory,
- resourceUri);
+ public IRestResourceCommand CreateDeleteWithStatusCodeResult(Uri resourceUri)
+ => new DeleteWithStatusCodeResultRestResourceCommand(
+ _httpClientProvider,
+ _httpRequestMessageFactory,
+ _httpResponseHandlerFactory,
+ resourceUri);
public IRestResourceCommand CreatePostWithStatusCodeResult(Uri resourceUri, TRequest request)
where TRequest : class
diff --git a/src/Mailtrap/GlobalUsings.cs b/src/Mailtrap/GlobalUsings.cs
index adfc7a3e..a1cc70e3 100644
--- a/src/Mailtrap/GlobalUsings.cs
+++ b/src/Mailtrap/GlobalUsings.cs
@@ -28,6 +28,10 @@
global using Mailtrap.Core.Rest;
global using Mailtrap.Core.Rest.Commands;
global using Mailtrap.Core.Validation;
+global using Mailtrap.Contacts;
+global using Mailtrap.Contacts.Models;
+global using Mailtrap.Contacts.Requests;
+global using Mailtrap.Contacts.Validators;
global using Mailtrap.Emails;
global using Mailtrap.Emails.Models;
global using Mailtrap.Emails.Requests;
diff --git a/src/Mailtrap/SendingDomains/SendingDomainResource.cs b/src/Mailtrap/SendingDomains/SendingDomainResource.cs
index 03d59bed..c644cce7 100644
--- a/src/Mailtrap/SendingDomains/SendingDomainResource.cs
+++ b/src/Mailtrap/SendingDomains/SendingDomainResource.cs
@@ -27,7 +27,7 @@ await RestResourceCommandFactory
public async Task Delete(CancellationToken cancellationToken = default)
=> await RestResourceCommandFactory
- .CreateDeleteWithStatusCodeResult(ResourceUri)
+ .CreateDeleteWithStatusCodeResult(ResourceUri)
.Execute(cancellationToken)
.ConfigureAwait(false);
}
diff --git a/tests/Mailtrap.IntegrationTests/Contacts/ContactsIntegrationTests.cs b/tests/Mailtrap.IntegrationTests/Contacts/ContactsIntegrationTests.cs
new file mode 100644
index 00000000..198fd89e
--- /dev/null
+++ b/tests/Mailtrap.IntegrationTests/Contacts/ContactsIntegrationTests.cs
@@ -0,0 +1,310 @@
+namespace Mailtrap.IntegrationTests.Contacts;
+
+
+[TestFixture]
+internal sealed class ContactsIntegrationTests
+{
+ private const string Feature = "Contacts";
+
+ private readonly long _accountId;
+ private readonly Uri _resourceUri = null!;
+ private readonly MailtrapClientOptions _clientConfig = null!;
+ private readonly JsonSerializerOptions _jsonSerializerOptions = null!;
+
+
+ public ContactsIntegrationTests()
+ {
+ var random = TestContext.CurrentContext.Random;
+
+ _accountId = random.NextLong();
+ _resourceUri = EndpointsTestConstants.ApiDefaultUrl
+ .Append(
+ UrlSegmentsTestConstants.ApiRootSegment,
+ UrlSegmentsTestConstants.AccountsSegment)
+ .Append(_accountId)
+ .Append(UrlSegmentsTestConstants.ContactsSegment);
+
+ var token = random.GetString();
+ _clientConfig = new MailtrapClientOptions(token);
+ _jsonSerializerOptions = _clientConfig.ToJsonSerializerOptions();
+ }
+
+
+ [Test]
+ public async Task GetAll_Success()
+ {
+ // Arrange
+ var httpMethod = HttpMethod.Get;
+ var requestUri = _resourceUri.AbsoluteUri;
+
+ using var responseContent = await Feature.LoadFileToStringContent();
+
+ using var mockHttp = new MockHttpMessageHandler();
+ mockHttp
+ .Expect(httpMethod, requestUri)
+ .WithHeaders("Authorization", $"Bearer {_clientConfig.ApiToken}")
+ .WithHeaders("Accept", MimeTypes.Application.Json)
+ .WithHeaders("User-Agent", HeaderValues.UserAgent.ToString())
+ .Respond(HttpStatusCode.OK, responseContent);
+
+ var serviceCollection = new ServiceCollection();
+ serviceCollection
+ .AddMailtrapClient(_clientConfig)
+ .ConfigurePrimaryHttpMessageHandler(() => mockHttp);
+
+ using var services = serviceCollection.BuildServiceProvider();
+ var client = services.GetRequiredService();
+
+ // Act
+ var result = await client
+ .Account(_accountId)
+ .Contacts()
+ .GetAll()
+ .ConfigureAwait(false);
+
+ // Assert
+ mockHttp.VerifyNoOutstandingExpectation();
+
+ result.Should()
+ .NotBeNull().And
+ .HaveCount(3);
+ }
+
+ [Test]
+ public async Task Create_Success()
+ {
+ // Arrange
+ var httpMethod = HttpMethod.Post;
+ var requestUri = _resourceUri.AbsoluteUri;
+
+ var contactEmail = $"{TestContext.CurrentContext.Random.GetString(10)}@mailtrap.io";
+ var request = new CreateContactRequest(contactEmail);
+
+ using var responseContent = await Feature.LoadFileToStringContent();
+
+ using var mockHttp = new MockHttpMessageHandler();
+ mockHttp
+ .Expect(httpMethod, requestUri)
+ .WithHeaders("Authorization", $"Bearer {_clientConfig.ApiToken}")
+ .WithHeaders("Accept", MimeTypes.Application.Json)
+ .WithHeaders("User-Agent", HeaderValues.UserAgent.ToString())
+ .WithJsonContent(request.ToDto(), _jsonSerializerOptions)
+ .Respond(HttpStatusCode.Created, responseContent);
+
+ var serviceCollection = new ServiceCollection();
+ serviceCollection
+ .AddMailtrapClient(_clientConfig)
+ .ConfigurePrimaryHttpMessageHandler(() => mockHttp);
+
+ using var services = serviceCollection.BuildServiceProvider();
+ var client = services.GetRequiredService();
+
+ // Act
+ var result = await client
+ .Account(_accountId)
+ .Contacts()
+ .Create(request)
+ .ConfigureAwait(false);
+
+ // Assert
+ mockHttp.VerifyNoOutstandingExpectation();
+
+ result.Should().NotBeNull();
+ }
+
+ [Test]
+ public async Task Create_ShouldFailValidation_WhenEmailIsNotValid([Values(1, 101)] int length)
+ {
+ // Arrange
+ var httpMethod = HttpMethod.Post;
+ var requestUri = _resourceUri.AbsoluteUri;
+
+ var contactEmail = TestContext.CurrentContext.Random.GetString(length);
+ var request = new CreateContactRequest(contactEmail);
+
+ using var mockHttp = new MockHttpMessageHandler();
+ var mockedRequest = mockHttp
+ .Expect(httpMethod, requestUri)
+ .WithHeaders("Authorization", $"Bearer {_clientConfig.ApiToken}")
+ .WithHeaders("Accept", MimeTypes.Application.Json)
+ .WithHeaders("User-Agent", HeaderValues.UserAgent.ToString())
+ .WithJsonContent(request.ToDto(), _jsonSerializerOptions)
+ .Respond(HttpStatusCode.UnprocessableContent);
+
+ var serviceCollection = new ServiceCollection();
+ serviceCollection
+ .AddMailtrapClient(_clientConfig)
+ .ConfigurePrimaryHttpMessageHandler(() => mockHttp);
+
+ using var services = serviceCollection.BuildServiceProvider();
+ var client = services.GetRequiredService();
+
+ // Act
+ var act = () => client
+ .Account(_accountId)
+ .Contacts()
+ .Create(request);
+
+ // Assert
+ await act.Should().ThrowAsync();
+
+ mockHttp.GetMatchCount(mockedRequest).Should().Be(0);
+ }
+
+ [Test]
+ public async Task GetDetails_Success()
+ {
+ // Arrange
+ var httpMethod = HttpMethod.Get;
+ var contactId = TestContext.CurrentContext.Random.NextGuid().ToString();
+ var requestUri = _resourceUri.Append(contactId).AbsoluteUri;
+
+ using var responseContent = await Feature.LoadFileToStringContent();
+
+ using var mockHttp = new MockHttpMessageHandler();
+ mockHttp
+ .Expect(httpMethod, requestUri)
+ .WithHeaders("Authorization", $"Bearer {_clientConfig.ApiToken}")
+ .WithHeaders("Accept", MimeTypes.Application.Json)
+ .WithHeaders("User-Agent", HeaderValues.UserAgent.ToString())
+ .Respond(HttpStatusCode.OK, responseContent);
+
+ var serviceCollection = new ServiceCollection();
+ serviceCollection
+ .AddMailtrapClient(_clientConfig)
+ .ConfigurePrimaryHttpMessageHandler(() => mockHttp);
+
+ using var services = serviceCollection.BuildServiceProvider();
+ var client = services.GetRequiredService();
+
+ // Act
+ var result = await client
+ .Account(_accountId)
+ .Contact(contactId)
+ .GetDetails()
+ .ConfigureAwait(false);
+
+ // Assert
+ mockHttp.VerifyNoOutstandingExpectation();
+
+ result.Should().NotBeNull();
+ }
+
+ [Test]
+ public async Task Update_Success()
+ {
+ // Arrange
+ var httpMethod = HttpMethodEx.Patch;
+ var contactId = TestContext.CurrentContext.Random.NextGuid().ToString();
+ var requestUri = _resourceUri.Append(contactId).AbsoluteUri;
+
+ var updatedEmail = $"{TestContext.CurrentContext.Random.GetString(10)}@mailtrap.io";
+ var request = new UpdateContactRequest(updatedEmail);
+
+ using var responseContent = await Feature.LoadFileToStringContent();
+
+ using var mockHttp = new MockHttpMessageHandler();
+ mockHttp
+ .Expect(httpMethod, requestUri)
+ .WithHeaders("Authorization", $"Bearer {_clientConfig.ApiToken}")
+ .WithHeaders("Accept", MimeTypes.Application.Json)
+ .WithHeaders("User-Agent", HeaderValues.UserAgent.ToString())
+ .WithJsonContent(request.ToDto(), _jsonSerializerOptions)
+ .Respond(HttpStatusCode.OK, responseContent);
+
+ var serviceCollection = new ServiceCollection();
+ serviceCollection
+ .AddMailtrapClient(_clientConfig)
+ .ConfigurePrimaryHttpMessageHandler(() => mockHttp);
+
+ using var services = serviceCollection.BuildServiceProvider();
+ var client = services.GetRequiredService();
+
+ // Act
+ var result = await client
+ .Account(_accountId)
+ .Contact(contactId)
+ .Update(request)
+ .ConfigureAwait(false);
+
+ // Assert
+ mockHttp.VerifyNoOutstandingExpectation();
+
+ result.Should().NotBeNull();
+ }
+
+ [Test]
+ public async Task Update_ShouldFailValidation_WhenEmailIsNotValid([Values(1, 101)] int length)
+ {
+ // Arrange
+ var httpMethod = HttpMethodEx.Patch;
+ var contactId = TestContext.CurrentContext.Random.NextGuid().ToString();
+ var requestUri = _resourceUri.Append(contactId).AbsoluteUri;
+
+ var updatedEmail = TestContext.CurrentContext.Random.GetString(length);
+ var request = new UpdateContactRequest(updatedEmail);
+
+ using var mockHttp = new MockHttpMessageHandler();
+ var mockedRequest = mockHttp
+ .Expect(httpMethod, requestUri)
+ .WithHeaders("Authorization", $"Bearer {_clientConfig.ApiToken}")
+ .WithHeaders("Accept", MimeTypes.Application.Json)
+ .WithHeaders("User-Agent", HeaderValues.UserAgent.ToString())
+ .WithJsonContent(request.ToDto(), _jsonSerializerOptions)
+ .Respond(HttpStatusCode.UnprocessableContent);
+
+ var serviceCollection = new ServiceCollection();
+ serviceCollection
+ .AddMailtrapClient(_clientConfig)
+ .ConfigurePrimaryHttpMessageHandler(() => mockHttp);
+
+ using var services = serviceCollection.BuildServiceProvider();
+ var client = services.GetRequiredService();
+
+ // Act
+ var act = () => client
+ .Account(_accountId)
+ .Contact(contactId)
+ .Update(request);
+
+ // Assert
+ await act.Should().ThrowAsync();
+
+ mockHttp.GetMatchCount(mockedRequest).Should().Be(0);
+ }
+
+ [Test]
+ public async Task Delete_Success()
+ {
+ // Arrange
+ var httpMethod = HttpMethod.Delete;
+ var contactId = TestContext.CurrentContext.Random.NextGuid().ToString();
+ var requestUri = _resourceUri.Append(contactId).AbsoluteUri;
+
+ using var mockHttp = new MockHttpMessageHandler();
+ mockHttp
+ .Expect(httpMethod, requestUri)
+ .WithHeaders("Authorization", $"Bearer {_clientConfig.ApiToken}")
+ .WithHeaders("Accept", MimeTypes.Application.Json)
+ .WithHeaders("User-Agent", HeaderValues.UserAgent.ToString())
+ .Respond(HttpStatusCode.NoContent);
+
+ var serviceCollection = new ServiceCollection();
+ serviceCollection
+ .AddMailtrapClient(_clientConfig)
+ .ConfigurePrimaryHttpMessageHandler(() => mockHttp);
+
+ using var services = serviceCollection.BuildServiceProvider();
+ var client = services.GetRequiredService();
+
+ // Act
+ await client
+ .Account(_accountId)
+ .Contact(contactId)
+ .Delete()
+ .ConfigureAwait(false);
+
+ // Assert
+ mockHttp.VerifyNoOutstandingExpectation();
+ }
+}
diff --git a/tests/Mailtrap.IntegrationTests/Contacts/Create_Success.json b/tests/Mailtrap.IntegrationTests/Contacts/Create_Success.json
new file mode 100644
index 00000000..b6dd3d8f
--- /dev/null
+++ b/tests/Mailtrap.IntegrationTests/Contacts/Create_Success.json
@@ -0,0 +1,18 @@
+{
+ "data": {
+ "id": "018dd5e3-f6d2-7c00-8f9b-e5c3f2d8a132",
+ "status": "subscribed",
+ "email": "john.smith@example.com",
+ "fields": {
+ "first_name": "John",
+ "last_name": "Smith"
+ },
+ "list_ids": [
+ 1,
+ 2,
+ 3
+ ],
+ "created_at": 1742820600230,
+ "updated_at": 1742820600230
+ }
+}
diff --git a/tests/Mailtrap.IntegrationTests/Contacts/GetAll_Success.json b/tests/Mailtrap.IntegrationTests/Contacts/GetAll_Success.json
new file mode 100644
index 00000000..ac90ac8a
--- /dev/null
+++ b/tests/Mailtrap.IntegrationTests/Contacts/GetAll_Success.json
@@ -0,0 +1,49 @@
+[
+ {
+ "id": "018dd5e3-f6d2-7c00-8f9b-e5c3f2d8a132",
+ "status": "subscribed",
+ "email": "john.smith@example.com",
+ "fields": {
+ "first_name": "John",
+ "last_name": "Smith"
+ },
+ "list_ids": [
+ 1,
+ 2,
+ 3
+ ],
+ "created_at": 1742820600230,
+ "updated_at": 1742820600230
+ },
+ {
+ "id": "7c5a9d84-3f2b-4c1e-9a6b-2f7d3a8c5e10",
+ "status": "unsubscribed",
+ "email": "doe.j@example.com",
+ "fields": {
+ "first_name": "John",
+ "last_name": "Doe"
+ },
+ "list_ids": [
+ 2,
+ 3,
+ 4
+ ],
+ "created_at": 1742820600231,
+ "updated_at": 1742820600231
+ },
+ {
+ "id": "7c1f2e5a-9d3b-4f2c-8a6b-2f1c9e7d4b90",
+ "email": "7c1f2e5a@example.com",
+ "fields": {
+ "first_name": "Alice",
+ "last_name": "Doe"
+ },
+ "list_ids": [
+ 12,
+ 13,
+ 14
+ ],
+ "created_at": 1742820600233,
+ "updated_at": 1742820600233
+ }
+]
diff --git a/tests/Mailtrap.IntegrationTests/Contacts/GetDetails_Success.json b/tests/Mailtrap.IntegrationTests/Contacts/GetDetails_Success.json
new file mode 100644
index 00000000..b6dd3d8f
--- /dev/null
+++ b/tests/Mailtrap.IntegrationTests/Contacts/GetDetails_Success.json
@@ -0,0 +1,18 @@
+{
+ "data": {
+ "id": "018dd5e3-f6d2-7c00-8f9b-e5c3f2d8a132",
+ "status": "subscribed",
+ "email": "john.smith@example.com",
+ "fields": {
+ "first_name": "John",
+ "last_name": "Smith"
+ },
+ "list_ids": [
+ 1,
+ 2,
+ 3
+ ],
+ "created_at": 1742820600230,
+ "updated_at": 1742820600230
+ }
+}
diff --git a/tests/Mailtrap.IntegrationTests/Contacts/Update_Success.json b/tests/Mailtrap.IntegrationTests/Contacts/Update_Success.json
new file mode 100644
index 00000000..bbc47512
--- /dev/null
+++ b/tests/Mailtrap.IntegrationTests/Contacts/Update_Success.json
@@ -0,0 +1,19 @@
+{
+ "action": "updated",
+ "data": {
+ "id": "018dd5e3-f6d2-7c00-8f9b-e5c3f2d8a132",
+ "status": "subscribed",
+ "email": "john.smith@example.com",
+ "fields": {
+ "first_name": "John",
+ "last_name": "Smith"
+ },
+ "list_ids": [
+ 1,
+ 2,
+ 3
+ ],
+ "created_at": 1740659901189,
+ "updated_at": 1742903266889
+ }
+}
diff --git a/tests/Mailtrap.IntegrationTests/GlobalUsings.cs b/tests/Mailtrap.IntegrationTests/GlobalUsings.cs
index a98cc05e..da82392b 100644
--- a/tests/Mailtrap.IntegrationTests/GlobalUsings.cs
+++ b/tests/Mailtrap.IntegrationTests/GlobalUsings.cs
@@ -13,6 +13,7 @@
global using Mailtrap.Core.Exceptions;
global using Mailtrap.Core.Extensions;
global using Mailtrap.Core.Models;
+global using Mailtrap.Contacts.Requests;
global using Mailtrap.Emails;
global using Mailtrap.Emails.Requests;
global using Mailtrap.Emails.Responses;
diff --git a/tests/Mailtrap.IntegrationTests/TestConstants/UrlSegmentsTestConstants.cs b/tests/Mailtrap.IntegrationTests/TestConstants/UrlSegmentsTestConstants.cs
index 300d6e78..34d15d5e 100644
--- a/tests/Mailtrap.IntegrationTests/TestConstants/UrlSegmentsTestConstants.cs
+++ b/tests/Mailtrap.IntegrationTests/TestConstants/UrlSegmentsTestConstants.cs
@@ -17,4 +17,5 @@ internal static class UrlSegmentsTestConstants
internal static string EmailsSegment { get; } = "messages";
internal static string AttachmentsSegment { get; } = "attachments";
internal static string SendEmailSegment { get; } = "send";
+ internal static string ContactsSegment { get; } = "contacts";
}
diff --git a/tests/Mailtrap.UnitTests/Contacts/ContactCollectionResourceTests.cs b/tests/Mailtrap.UnitTests/Contacts/ContactCollectionResourceTests.cs
new file mode 100644
index 00000000..f39f7872
--- /dev/null
+++ b/tests/Mailtrap.UnitTests/Contacts/ContactCollectionResourceTests.cs
@@ -0,0 +1,51 @@
+namespace Mailtrap.UnitTests.Contacts;
+
+
+[TestFixture]
+internal sealed class ContactCollectionResourceTests
+{
+ private readonly IRestResourceCommandFactory _commandFactoryMock = Mock.Of();
+ private readonly Uri _resourceUri = EndpointsTestConstants.ApiDefaultUrl
+ .Append(
+ UrlSegmentsTestConstants.ApiRootSegment,
+ UrlSegmentsTestConstants.AccountsSegment)
+ .Append(TestContext.CurrentContext.Random.NextLong())
+ .Append(UrlSegmentsTestConstants.ContactsSegment);
+
+
+ #region Constructor
+
+ [Test]
+ public void Constructor_ShouldThrowArgumentNullException_WhenCommandFactoryIsNull()
+ {
+ // Act
+ var act = () => new ContactCollectionResource(null!, _resourceUri);
+
+ // Assert
+ act.Should().Throw();
+ }
+
+ [Test]
+ public void Constructor_ShouldThrowArgumentNullException_WhenUriIsNull()
+ {
+ // Act
+ var act = () => new ContactCollectionResource(_commandFactoryMock, null!);
+
+ // Assert
+ act.Should().Throw();
+ }
+
+ [Test]
+ public void ResourceUri_ShouldBeInitializedProperly()
+ {
+ // Arrange
+ var client = CreateResource();
+
+ // Assert
+ client.ResourceUri.Should().Be(_resourceUri);
+ }
+
+ #endregion
+
+ private ContactCollectionResource CreateResource() => new(_commandFactoryMock, _resourceUri);
+}
diff --git a/tests/Mailtrap.UnitTests/Contacts/ContactResourceTests.cs b/tests/Mailtrap.UnitTests/Contacts/ContactResourceTests.cs
new file mode 100644
index 00000000..f8ef8e6f
--- /dev/null
+++ b/tests/Mailtrap.UnitTests/Contacts/ContactResourceTests.cs
@@ -0,0 +1,51 @@
+namespace Mailtrap.UnitTests.Contacts;
+
+
+[TestFixture]
+internal sealed class ContactctResourceTests
+{
+ private readonly IRestResourceCommandFactory _commandFactoryMock = Mock.Of();
+ private readonly Uri _resourceUri = EndpointsTestConstants.ApiDefaultUrl
+ .Append(
+ UrlSegmentsTestConstants.ApiRootSegment,
+ UrlSegmentsTestConstants.AccountsSegment)
+ .Append(TestContext.CurrentContext.Random.NextLong())
+ .Append(UrlSegmentsTestConstants.ContactsSegment)
+ .Append(TestContext.CurrentContext.Random.NextGuid().ToString());
+
+ #region Constructor
+
+ [Test]
+ public void Constructor_ShouldThrowArgumentNullException_WhenCommandFactoryIsNull()
+ {
+ // Act
+ var act = () => new ContactResource(null!, _resourceUri);
+
+ // Assert
+ act.Should().Throw();
+ }
+
+ [Test]
+ public void Constructor_ShouldThrowArgumentNullException_WhenUriIsNull()
+ {
+ // Act
+ var act = () => new ContactResource(_commandFactoryMock, null!);
+
+ // Assert
+ act.Should().Throw();
+ }
+
+ [Test]
+ public void ResourceUri_ShouldBeInitializedProperly()
+ {
+ // Arrange
+ var client = CreateResource();
+
+ // Assert
+ client.ResourceUri.Should().Be(_resourceUri);
+ }
+
+ #endregion
+
+ private ContactResource CreateResource() => new(_commandFactoryMock, _resourceUri);
+}
diff --git a/tests/Mailtrap.UnitTests/Contacts/Requests/ContactRequestTests.cs b/tests/Mailtrap.UnitTests/Contacts/Requests/ContactRequestTests.cs
new file mode 100644
index 00000000..023cf612
--- /dev/null
+++ b/tests/Mailtrap.UnitTests/Contacts/Requests/ContactRequestTests.cs
@@ -0,0 +1,52 @@
+namespace Mailtrap.UnitTests.Contacts.Requests;
+
+
+[TestFixture]
+internal sealed class ContactRequestTests
+{
+ [Test]
+ public void Constructor_ShouldThrowArgumentNullException_WhenEmailIsNull()
+ {
+ var act = () => new ContactRequest(null!);
+
+ act.Should().Throw();
+ }
+
+ [Test]
+ public void Constructor_ShouldThrowArgumentNullException_WhenEmailIsEmpty()
+ {
+ var act = () => new ContactRequest(string.Empty);
+
+ act.Should().Throw();
+ }
+
+ [Test]
+ public void Constructor_ShouldInitializeFieldsCorrectly()
+ {
+ // Arrange
+ var email = TestContext.CurrentContext.Random.GetString(5)
+ + "@"
+ + TestContext.CurrentContext.Random.GetString(5)
+ + ".com";
+
+ // Act
+ var request = new ContactRequest(email);
+
+ // Assert
+ request.Email.Should().Be(email);
+ }
+
+ [Test]
+ public void Validate_ShouldFail_WhenProvidedEmailLengthIsInvalid([Values(1, 101)] int length)
+ {
+ // Arrange
+ var email = TestContext.CurrentContext.Random.GetString(length);
+ var request = new ContactRequest(email);
+
+ // Act
+ var result = request.Validate();
+
+ // Assert
+ result.IsValid.Should().BeFalse();
+ }
+}
diff --git a/tests/Mailtrap.UnitTests/GlobalUsings.cs b/tests/Mailtrap.UnitTests/GlobalUsings.cs
index 63225f87..8523ffb9 100644
--- a/tests/Mailtrap.UnitTests/GlobalUsings.cs
+++ b/tests/Mailtrap.UnitTests/GlobalUsings.cs
@@ -23,6 +23,8 @@
global using Mailtrap.Core.Models;
global using Mailtrap.Core.Rest;
global using Mailtrap.Core.Rest.Commands;
+global using Mailtrap.Contacts;
+global using Mailtrap.Contacts.Requests;
global using Mailtrap.Emails;
global using Mailtrap.Emails.Models;
global using Mailtrap.Emails.Requests;
diff --git a/tests/Mailtrap.UnitTests/TestConstants/UrlSegmentsTestConstants.cs b/tests/Mailtrap.UnitTests/TestConstants/UrlSegmentsTestConstants.cs
index 7bbe6829..78a531c0 100644
--- a/tests/Mailtrap.UnitTests/TestConstants/UrlSegmentsTestConstants.cs
+++ b/tests/Mailtrap.UnitTests/TestConstants/UrlSegmentsTestConstants.cs
@@ -14,4 +14,5 @@ internal static class UrlSegmentsTestConstants
internal static string MessagesSegment { get; } = "messages";
internal static string AttachmentsSegment { get; } = "attachments";
internal static string SendEmailSegment { get; } = "send";
+ internal static string ContactsSegment { get; } = "contacts";
}