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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 72 additions & 0 deletions examples/contacts/contacts.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
from typing import Optional
from typing import Union

import mailtrap as mt
from mailtrap.models.common import DeletedObject
from mailtrap.models.contacts import Contact

API_TOKEN = "YOU_API_TOKEN"
ACCOUNT_ID = "YOU_ACCOUNT_ID"

client = mt.MailtrapClient(token=API_TOKEN, account_id=ACCOUNT_ID)
contacts_api = client.contacts_api.contacts


def create_contact(
email: str,
fields: Optional[dict[str, Union[str, int, float, bool]]] = None,
list_ids: Optional[list[int]] = None,
) -> Contact:
params = mt.CreateContactParams(
email=email,
fields=fields,
list_ids=list_ids,
)
return contacts_api.create(params)


def update_contact(
contact_id_or_email: str,
new_email: Optional[str] = None,
fields: Optional[dict[str, Union[str, int, float, bool]]] = None,
list_ids_included: Optional[list[int]] = None,
list_ids_excluded: Optional[list[int]] = None,
unsubscribed: Optional[bool] = None,
) -> Contact:
params = mt.UpdateContactParams(
email=new_email,
fields=fields,
list_ids_included=list_ids_included,
list_ids_excluded=list_ids_excluded,
unsubscribed=unsubscribed,
)
return contacts_api.update(contact_id_or_email, params)


def get_contact(contact_id_or_email: str) -> Contact:
return contacts_api.get_by_id(contact_id_or_email)


def delete_contact(contact_id_or_email: str) -> DeletedObject:
return contacts_api.delete(contact_id_or_email)


if __name__ == "__main__":
created_contact = create_contact(
email="[email protected]",
fields={
"first_name": "Test",
"last_name": "Test",
},
)
print(created_contact)
updated_contact = update_contact(
created_contact.id,
fields={
"first_name": "John",
"last_name": "Doe",
},
)
print(updated_contact)
deleted_contact = delete_contact(updated_contact.id)
print(deleted_contact)
3 changes: 2 additions & 1 deletion mailtrap/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,11 @@
from .exceptions import AuthorizationError
from .exceptions import ClientConfigurationError
from .exceptions import MailtrapError
from .models.contacts import ContactField
from .models.contacts import ContactListParams
from .models.contacts import CreateContactFieldParams
from .models.contacts import CreateContactParams
from .models.contacts import UpdateContactFieldParams
from .models.contacts import UpdateContactParams
from .models.mail import Address
from .models.mail import Attachment
from .models.mail import BaseMail
Expand Down
5 changes: 5 additions & 0 deletions mailtrap/api/contacts.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from mailtrap.api.resources.contact_fields import ContactFieldsApi
from mailtrap.api.resources.contact_lists import ContactListsApi
from mailtrap.api.resources.contacts import ContactsApi
from mailtrap.http import HttpClient


Expand All @@ -15,3 +16,7 @@ def contact_fields(self) -> ContactFieldsApi:
@property
def contact_lists(self) -> ContactListsApi:
return ContactListsApi(account_id=self._account_id, client=self._client)

@property
def contacts(self) -> ContactsApi:
return ContactsApi(account_id=self._account_id, client=self._client)
45 changes: 45 additions & 0 deletions mailtrap/api/resources/contacts.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
from typing import Optional
from urllib.parse import quote

from mailtrap.http import HttpClient
from mailtrap.models.common import DeletedObject
from mailtrap.models.contacts import Contact
from mailtrap.models.contacts import ContactResponse
from mailtrap.models.contacts import CreateContactParams
from mailtrap.models.contacts import UpdateContactParams


class ContactsApi:
def __init__(self, client: HttpClient, account_id: str) -> None:
self._account_id = account_id
self._client = client

def get_by_id(self, contact_id_or_email: str) -> Contact:
response = self._client.get(self._api_path(contact_id_or_email))
return ContactResponse(**response).data

def create(self, contact_params: CreateContactParams) -> Contact:
response = self._client.post(
self._api_path(),
json={"contact": contact_params.api_data},
)
return ContactResponse(**response).data

def update(
self, contact_id_or_email: str, contact_params: UpdateContactParams
) -> Contact:
response = self._client.patch(
self._api_path(contact_id_or_email),
json={"contact": contact_params.api_data},
)
return ContactResponse(**response).data

def delete(self, contact_id_or_email: str) -> DeletedObject:
self._client.delete(self._api_path(contact_id_or_email))
return DeletedObject(contact_id_or_email)

def _api_path(self, contact_id_or_email: Optional[str] = None) -> str:
path = f"/api/accounts/{self._account_id}/contacts"
if contact_id_or_email is not None:
return f"{path}/{quote(contact_id_or_email, safe='')}"
return path
3 changes: 2 additions & 1 deletion mailtrap/models/common.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from typing import Any
from typing import TypeVar
from typing import Union
from typing import cast

from pydantic import TypeAdapter
Expand All @@ -20,4 +21,4 @@ def api_data(self: T) -> dict[str, Any]:

@dataclass
class DeletedObject:
id: int
id: Union[int, str]
56 changes: 56 additions & 0 deletions mailtrap/models/contacts.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
from enum import Enum
from typing import Optional
from typing import Union

from pydantic.dataclasses import dataclass

Expand Down Expand Up @@ -39,3 +41,57 @@ class ContactListParams(RequestParams):
class ContactList:
id: int
name: str


class ContactStatus(str, Enum):
SUBSCRIBED = "subscribed"
UNSUBSCRIBED = "unsubscribed"


@dataclass
class CreateContactParams(RequestParams):
email: str
fields: Optional[dict[str, Union[str, int, float, bool]]] = (
None # field_merge_tag: value
)
list_ids: Optional[list[int]] = None


@dataclass
class UpdateContactParams(RequestParams):
email: Optional[str] = None
fields: Optional[dict[str, Union[str, int, float, bool]]] = (
None # field_merge_tag: value
)
list_ids_included: Optional[list[int]] = None
list_ids_excluded: Optional[list[int]] = None
unsubscribed: Optional[bool] = None

def __post_init__(self) -> None:
if all(
value is None
for value in [
self.email,
self.fields,
self.list_ids_included,
self.list_ids_excluded,
self.unsubscribed,
]
):
raise ValueError("At least one field must be provided for update action")


@dataclass
class Contact:
id: str
email: str
fields: dict[str, Union[str, int, float, bool]] # field_merge_tag: value
list_ids: list[int]
status: ContactStatus
created_at: int
updated_at: int


@dataclass
class ContactResponse:
data: Contact
10 changes: 10 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,13 @@
NOT_FOUND_STATUS_CODE = 404
NOT_FOUND_ERROR_MESSAGE = "Not Found"
NOT_FOUND_RESPONSE = {"error": NOT_FOUND_ERROR_MESSAGE}

RATE_LIMIT_ERROR_STATUS_CODE = 429
RATE_LIMIT_ERROR_MESSAGE = "Rate limit exceeded"
RATE_LIMIT_ERROR_RESPONSE = {"errors": RATE_LIMIT_ERROR_MESSAGE}

INTERNAL_SERVER_ERROR_STATUS_CODE = 500
INTERNAL_SERVER_ERROR_MESSAGE = "Unexpected error"
INTERNAL_SERVER_ERROR_RESPONSE = {"errors": "Unexpected error"}

VALIDATION_ERRORS_STATUS_CODE = 422
95 changes: 95 additions & 0 deletions tests/unit/api/test_contact_fields.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,18 @@
BASE_CONTACT_FIELDS_URL = (
f"https://{GENERAL_HOST}/api/accounts/{ACCOUNT_ID}/contacts/fields"
)
VALIDATION_ERRORS_RESPONSE = {
"errors": {
"name": [["is too long (maximum is 80 characters)", "has already been taken"]],
"merge_tag": [
["is too long (maximum is 80 characters)", "has already been taken"]
],
}
}
VALIDATION_ERRORS_MESSAGE = (
"name: ['is too long (maximum is 80 characters)', 'has already been taken']; "
"merge_tag: ['is too long (maximum is 80 characters)', 'has already been taken']"
)


@pytest.fixture
Expand Down Expand Up @@ -62,6 +74,16 @@ class TestContactsApi:
conftest.FORBIDDEN_RESPONSE,
conftest.FORBIDDEN_ERROR_MESSAGE,
),
(
conftest.RATE_LIMIT_ERROR_STATUS_CODE,
conftest.RATE_LIMIT_ERROR_RESPONSE,
conftest.RATE_LIMIT_ERROR_MESSAGE,
),
(
conftest.INTERNAL_SERVER_ERROR_STATUS_CODE,
conftest.INTERNAL_SERVER_ERROR_RESPONSE,
conftest.INTERNAL_SERVER_ERROR_MESSAGE,
),
],
)
@responses.activate
Expand Down Expand Up @@ -117,6 +139,16 @@ def test_get_contact_fields_should_return_contact_field_list(
conftest.NOT_FOUND_RESPONSE,
conftest.NOT_FOUND_ERROR_MESSAGE,
),
(
conftest.RATE_LIMIT_ERROR_STATUS_CODE,
conftest.RATE_LIMIT_ERROR_RESPONSE,
conftest.RATE_LIMIT_ERROR_MESSAGE,
),
(
conftest.INTERNAL_SERVER_ERROR_STATUS_CODE,
conftest.INTERNAL_SERVER_ERROR_RESPONSE,
conftest.INTERNAL_SERVER_ERROR_MESSAGE,
),
],
)
@responses.activate
Expand Down Expand Up @@ -169,6 +201,21 @@ def test_get_contact_field_should_return_contact_field(
conftest.FORBIDDEN_RESPONSE,
conftest.FORBIDDEN_ERROR_MESSAGE,
),
(
conftest.RATE_LIMIT_ERROR_STATUS_CODE,
conftest.RATE_LIMIT_ERROR_RESPONSE,
conftest.RATE_LIMIT_ERROR_MESSAGE,
),
(
conftest.INTERNAL_SERVER_ERROR_STATUS_CODE,
conftest.INTERNAL_SERVER_ERROR_RESPONSE,
conftest.INTERNAL_SERVER_ERROR_MESSAGE,
),
(
conftest.VALIDATION_ERRORS_STATUS_CODE,
VALIDATION_ERRORS_RESPONSE,
VALIDATION_ERRORS_MESSAGE,
),
],
)
@responses.activate
Expand Down Expand Up @@ -235,6 +282,21 @@ def test_create_contact_field_should_return_created_contact_field(
conftest.NOT_FOUND_RESPONSE,
conftest.NOT_FOUND_ERROR_MESSAGE,
),
(
conftest.RATE_LIMIT_ERROR_STATUS_CODE,
conftest.RATE_LIMIT_ERROR_RESPONSE,
conftest.RATE_LIMIT_ERROR_MESSAGE,
),
(
conftest.INTERNAL_SERVER_ERROR_STATUS_CODE,
conftest.INTERNAL_SERVER_ERROR_RESPONSE,
conftest.INTERNAL_SERVER_ERROR_MESSAGE,
),
(
conftest.VALIDATION_ERRORS_STATUS_CODE,
VALIDATION_ERRORS_RESPONSE,
VALIDATION_ERRORS_MESSAGE,
),
],
)
@responses.activate
Expand Down Expand Up @@ -301,6 +363,39 @@ def test_update_contact_field_should_return_updated_contact_field(
conftest.NOT_FOUND_RESPONSE,
conftest.NOT_FOUND_ERROR_MESSAGE,
),
(
conftest.RATE_LIMIT_ERROR_STATUS_CODE,
conftest.RATE_LIMIT_ERROR_RESPONSE,
conftest.RATE_LIMIT_ERROR_MESSAGE,
),
(
conftest.INTERNAL_SERVER_ERROR_STATUS_CODE,
conftest.INTERNAL_SERVER_ERROR_RESPONSE,
conftest.INTERNAL_SERVER_ERROR_MESSAGE,
),
(
conftest.VALIDATION_ERRORS_STATUS_CODE,
{
"errors": {
"usage": [
(
"This field is used in the steps of automation(s): "
"%{automation names}."
),
(
"This field is used in the conditions of segment(s): "
"{segment names}."
),
]
}
},
(
"usage: This field is used in the steps of automation(s): "
"%{automation names}.; "
"usage: This field is used in the conditions of segment(s): "
"{segment names}."
),
),
],
)
@responses.activate
Expand Down
Loading
Loading