diff --git a/README.md b/README.md index 5b05ec1..6d6b561 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,11 @@ MailerSend Python SDK - [Send email with attachment](#send-email-with-attachment) - [Send bulk email](#send-bulk-email) - [Get bulk email status](#get-bulk-email-status) + - [Emails](#emails) + - [Get a list of emails](#get-a-list-of-emails) + - [Filter emails](#filter-emails) + - [Paginate through emails](#paginate-through-emails) + - [Get a single email](#get-a-single-email) - [Activity](#activity) - [Get a list of activities](#get-a-list-of-activities) - [Get activity with filters](#get-activity-with-filters) @@ -831,6 +836,167 @@ ms = MailerSendClient() response = ms.emails.get_bulk_status("bulk-email-id") ``` +## Emails + +An email is the record of a message delivered to one recipient. Use these requests to list the emails sent from one of your domains and to retrieve a single email together with its activity events. + +`ms.emails.list()` returns one row per email. If you need one row per *event* instead, use [Activity](#activity). + +### Get a list of emails + +```python +from mailersend import MailerSendClient, EmailsBuilder +from datetime import datetime, timedelta + +ms = MailerSendClient() + +date_from = int((datetime.now() - timedelta(days=7)).timestamp()) +date_to = int(datetime.now().timestamp()) + +request = (EmailsBuilder() + .domain_id("domain-id") + .date_from(date_from) + .date_to(date_to) + .limit(50) + .build_list_request()) + +response = ms.emails.list(request) + +for email in response["data"]: + print(email["id"], email["status"], email["to"], email["subject"]) +``` + +`domain_id`, `date_from` and `date_to` are required. Emails are returned newest first. + +`date_from` and `date_to` accept a `datetime` object, a Unix timestamp, or a datetime string such as `"2015-10-01 00:00:00"`. A `datetime` is converted to a Unix timestamp for you. + +| Builder method | Type | Required | Details | +|----------------------|-------------------------|----------|------------------------------------------------------------------------------------------------------------| +| `domain_id()` | `str` | yes | Must be a domain that belongs to your account. An unknown ID returns `404`. | +| `date_from()` | `datetime \| int \| str`| yes | Must be lower than `date_to`. The allowed timeframe depends on your plan's data retention limit (1–30 days). | +| `date_to()` | `datetime \| int \| str`| yes | Must be higher than `date_from` and must not be in the future. | +| `page()` | `int` | no | Min `1`, max `1000`, default `1`. See [Paginate through emails](#paginate-through-emails). | +| `limit()` | `int` | no | Min `10`, max `100`, default `25`. | +| `status()` | `str \| list[str]` | no | Any of `queued`, `sent`, `rejected`, `delivered`. Combined with `OR`. | +| `interaction()` | `str \| list[str]` | no | Any of `opened`, `clicked`, `unsubscribed`, `complained`, `no_interaction`. Combined with `OR`. | +| `recipient_email()` | `str` | no | Exact, case-insensitive match. An unknown address returns `200` with an empty `data` array. | +| `message_id()` | `str` | no | Alphanumeric. Exact match. | +| `template_id()` | `str` | no | Exact match. | +| `tag()` | `str` | no | Exact match against a value in the email's `tags` array. | +| `subject()` | `str` | no | Min 3 characters. Partial, case-insensitive match. | + +`add_status()` and `add_interaction()` append a single value instead of replacing the whole filter. + +Requires a token with either the `activity_read` or the `activity_full` scope. Requests are limited to 10 requests/minute, shared with [Get a list of activities](#get-a-list-of-activities). + +### Filter emails + +`status` and `interaction` values are combined with `OR` within each filter, and the two filters are combined with `AND`. The example below returns emails that are sent **or** delivered **and** that were opened. + +```python +from mailersend import MailerSendClient, EmailsBuilder +from datetime import datetime, timedelta + +ms = MailerSendClient() + +date_from = int((datetime.now() - timedelta(days=7)).timestamp()) +date_to = int(datetime.now().timestamp()) + +request = (EmailsBuilder() + .domain_id("domain-id") + .date_from(date_from) + .date_to(date_to) + .status(["sent", "delivered"]) + .interaction(["opened"]) + .recipient_email("tyra.cummerata@example.org") + .subject("Your order") + .tag("receipt") + .limit(50) + .build_list_request()) + +response = ms.emails.list(request) +``` + +`no_interaction` matches emails with none of `opened`, `clicked`, `unsubscribed` or `complained` recorded. It is a filter value only and is never returned in the response. + +### Paginate through emails + +Use `page()` and `limit()`, the same way as [Get a list of activities](#get-a-list-of-activities). Keep `domain_id`, `date_from`, `date_to` and every filter unchanged between pages — dropping any required parameter returns a `422`. + +The response carries a `links` and a `meta` object: + +```python +response["links"] # {"first": "", "last": None, "prev": None, "next": ""} +response["meta"] # {"current_page": 1, "current_page_url": "", "from": 1, + # "path": "", "per_page": 10, "to": 3} +``` + +There is no `total` and no `last_page`, so you cannot tell up front how many pages there are — walk pages until `links["next"]` is `None`. `links["last"]` is always `None`. + +```python +from mailersend import MailerSendClient, EmailsBuilder +from datetime import datetime, timedelta + +ms = MailerSendClient() + +builder = (EmailsBuilder() + .domain_id("domain-id") + .date_from(int((datetime.now() - timedelta(days=7)).timestamp())) + .date_to(int(datetime.now().timestamp())) + .limit(100)) + +page = 1 + +while True: + response = ms.emails.list(builder.page(page).build_list_request()) + + for email in response["data"]: + print(email["id"], email["status"], email["to"]) + + if not response["links"]["next"]: + break + + page += 1 +``` + +Each row in `data` contains `id`, `from`, `to`, `subject`, `text`, `html`, `template_id`, `domain_id`, `message_id`, `status`, `tags`, `interaction`, `suppression_reason`, `created_at`, `updated_at` and `headers`. `interaction` is an empty list when there was no interaction, and `suppression_reason` is only set when `status` is `rejected`. `text` and `html` are always `None` in list rows and the rows carry no events — use [Get a single email](#get-a-single-email) for the content and the activity. + +### Get a single email + +```python +from mailersend import MailerSendClient, EmailsBuilder + +ms = MailerSendClient() + +request = (EmailsBuilder() + .email_id("email-id") + .build_get_request()) + +response = ms.emails.get(request) + +print(response["data"]["subject"]) + +for event in response["data"]["activity"]: + print(event["type"], event["created_at"]) +``` + +`ms.emails.get()` also accepts the email ID directly: + +```python +response = ms.emails.get("email-id") +``` + +The response contains the email's `from`, `to`, `subject`, `text`, `html`, `template_id`, `domain_id`, `message_id`, `status`, `tags`, `interaction`, `suppression_reason`, `recipient`, `headers` and an `activity` array of the events recorded for it. `template_id` is `None` when the email was not sent from a template. + +Each entry in `activity` has an `id`, a `type` and a `created_at`, plus a `suppression_reason` on `suppressed` events. A few things to know about it: + +- Events are returned **newest first** and are **capped at 200 events per email**. There is no pagination on this array — use [Get a list of activities](#get-a-list-of-activities) if you need the complete event history for a domain. +- The `junk` event type is reported as `soft_bounced`. +- `deferred` and `suppressed` events are only included if your plan has those features enabled. They are available on the Starter plan and above. +- `activity` is returned even when content tracking is disabled for the domain. In that case `html` and `text` are `None` but the events are still present. + +Requires a token with one of the `email_full`, `activity_read` or `activity_full` scopes. + ## Activity ### Get a list of activities diff --git a/mailersend/__init__.py b/mailersend/__init__.py index 9a79ac3..5ed1a55 100644 --- a/mailersend/__init__.py +++ b/mailersend/__init__.py @@ -12,7 +12,7 @@ AsyncMailerSendClient = None # type: ignore[assignment,misc] # Import all builders for better UX - users can import everything from main module -from .builders.email import EmailBuilder +from .builders.email import EmailBuilder, EmailsBuilder from .builders.activity import ActivityBuilder, SingleActivityBuilder from .builders.analytics import AnalyticsBuilder from .builders.domains import DomainsBuilder @@ -46,6 +46,11 @@ EmailRequest, EmailTrackingSettings, EmailHeader, + EmailActivityEvent, + EmailListItem, + EmailsListQueryParams, + EmailsListRequest, + EmailGetRequest, ) from .models.activity import ( ActivityRecipient, @@ -75,6 +80,7 @@ "AsyncMailerSendClient", # Builders - All available from main module for better UX "EmailBuilder", + "EmailsBuilder", "ActivityBuilder", "SingleActivityBuilder", "AnalyticsBuilder", @@ -110,6 +116,11 @@ "EmailRequest", "EmailTrackingSettings", "EmailHeader", + "EmailActivityEvent", + "EmailListItem", + "EmailsListQueryParams", + "EmailsListRequest", + "EmailGetRequest", # Activity models "ActivityRecipient", "ActivityEmail", diff --git a/mailersend/builders/__init__.py b/mailersend/builders/__init__.py index 75db6d0..4047ba8 100644 --- a/mailersend/builders/__init__.py +++ b/mailersend/builders/__init__.py @@ -5,7 +5,7 @@ complex email requests with intelligent defaults and validation. """ -from .email import EmailBuilder +from .email import EmailBuilder, EmailsBuilder from .activity import ActivityBuilder, SingleActivityBuilder from .analytics import AnalyticsBuilder from .domains import DomainsBuilder @@ -30,6 +30,7 @@ __all__ = [ "EmailBuilder", + "EmailsBuilder", "ActivityBuilder", "SingleActivityBuilder", "AnalyticsBuilder", diff --git a/mailersend/builders/email.py b/mailersend/builders/email.py index 5d1b13a..f69424a 100644 --- a/mailersend/builders/email.py +++ b/mailersend/builders/email.py @@ -18,6 +18,9 @@ EmailPersonalization, EmailTrackingSettings, EmailHeader, + EmailsListQueryParams, + EmailsListRequest, + EmailGetRequest, ) from ..exceptions import ValidationError @@ -766,3 +769,202 @@ def copy(self) -> "EmailBuilder": new_builder._headers = self._headers.copy() return new_builder + + +class EmailsBuilder: + """ + Fluent builder for constructing emails list and single email requests. + + Examples: + >>> # List the delivered emails that were opened + >>> request = (EmailsBuilder() + ... .domain_id("domain-id") + ... .date_from(1623073576) + ... .date_to(1623074976) + ... .limit(50) + ... .status(["sent", "delivered"]) + ... .interaction(["opened"]) + ... .build_list_request()) + + >>> # Get a single email with its activity events + >>> request = (EmailsBuilder() + ... .email_id("email-id") + ... .build_get_request()) + """ + + def __init__(self): + self._domain_id: Optional[str] = None + self._date_from: Optional[Union[datetime, int, str]] = None + self._date_to: Optional[Union[datetime, int, str]] = None + self._page: Optional[int] = None + self._limit: Optional[int] = None + self._status: List[str] = [] + self._interaction: List[str] = [] + self._recipient_email: Optional[str] = None + self._message_id: Optional[str] = None + self._template_id: Optional[str] = None + self._subject: Optional[str] = None + self._tag: Optional[str] = None + self._email_id: Optional[str] = None + + def domain_id(self, domain_id: str) -> "EmailsBuilder": + """Set the domain ID to list emails for (required).""" + self._domain_id = domain_id + return self + + def date_from(self, date_from: Union[datetime, int, str]) -> "EmailsBuilder": + """Set the start of the time window (required).""" + self._date_from = date_from + return self + + def date_to(self, date_to: Union[datetime, int, str]) -> "EmailsBuilder": + """Set the end of the time window (required).""" + self._date_to = date_to + return self + + def page(self, page: int) -> "EmailsBuilder": + """Set the page number (min 1, max 1000, default 1).""" + self._page = page + return self + + def limit(self, limit: int) -> "EmailsBuilder": + """Set the number of emails per page (min 10, max 100, default 25).""" + self._limit = limit + return self + + def status(self, status: Union[str, List[str]]) -> "EmailsBuilder": + """ + Set the status filter, replacing any previously set values. + + Possible values: queued, sent, rejected, delivered. + """ + self._status = [status] if isinstance(status, str) else list(status) + return self + + def add_status(self, status: str) -> "EmailsBuilder": + """Add a single status to the status filter.""" + if status not in self._status: + self._status.append(status) + return self + + def interaction(self, interaction: Union[str, List[str]]) -> "EmailsBuilder": + """ + Set the interaction filter, replacing any previously set values. + + Possible values: opened, clicked, unsubscribed, complained, + no_interaction. + """ + self._interaction = ( + [interaction] if isinstance(interaction, str) else list(interaction) + ) + return self + + def add_interaction(self, interaction: str) -> "EmailsBuilder": + """Add a single interaction to the interaction filter.""" + if interaction not in self._interaction: + self._interaction.append(interaction) + return self + + def recipient_email(self, recipient_email: str) -> "EmailsBuilder": + """Filter by recipient email address (exact match).""" + self._recipient_email = recipient_email + return self + + def message_id(self, message_id: str) -> "EmailsBuilder": + """Filter by the ID of the message that created the emails.""" + self._message_id = message_id + return self + + def template_id(self, template_id: str) -> "EmailsBuilder": + """Filter by template ID.""" + self._template_id = template_id + return self + + def subject(self, subject: str) -> "EmailsBuilder": + """Filter by subject (partial match, min 3 characters).""" + self._subject = subject + return self + + def tag(self, tag: str) -> "EmailsBuilder": + """Filter by tag (exact match against the email's tags).""" + self._tag = tag + return self + + def email_id(self, email_id: str) -> "EmailsBuilder": + """Set the email ID for a single email request.""" + self._email_id = email_id + return self + + def copy(self) -> "EmailsBuilder": + """Create a copy of this builder.""" + builder = EmailsBuilder() + builder._domain_id = self._domain_id + builder._date_from = self._date_from + builder._date_to = self._date_to + builder._page = self._page + builder._limit = self._limit + builder._status = self._status.copy() + builder._interaction = self._interaction.copy() + builder._recipient_email = self._recipient_email + builder._message_id = self._message_id + builder._template_id = self._template_id + builder._subject = self._subject + builder._tag = self._tag + builder._email_id = self._email_id + return builder + + def reset(self) -> "EmailsBuilder": + """Reset all parameters.""" + self.__init__() + return self + + def _convert_to_timestamp( + self, value: Union[datetime, int, str] + ) -> Union[int, str]: + """Convert datetime to a unix timestamp, leaving other values as-is.""" + if isinstance(value, datetime): + return int(value.timestamp()) + return value + + def build_list_request(self) -> EmailsListRequest: + """ + Build the EmailsListRequest object for listing emails. + + Raises: + ValidationError: If a required parameter is missing + """ + if not self._domain_id: + raise ValidationError("domain_id is required") + if self._date_from is None: + raise ValidationError("date_from is required") + if self._date_to is None: + raise ValidationError("date_to is required") + + query_params = EmailsListQueryParams( + domain_id=self._domain_id, + date_from=self._convert_to_timestamp(self._date_from), + date_to=self._convert_to_timestamp(self._date_to), + page=self._page, + limit=self._limit, + status=self._status or None, + interaction=self._interaction or None, + recipient_email=self._recipient_email, + message_id=self._message_id, + template_id=self._template_id, + subject=self._subject, + tag=self._tag, + ) + + return EmailsListRequest(query_params=query_params) + + def build_get_request(self) -> EmailGetRequest: + """ + Build the EmailGetRequest object for getting a single email. + + Raises: + ValidationError: If email_id is missing + """ + if not self._email_id: + raise ValidationError("email_id is required") + + return EmailGetRequest(email_id=self._email_id) diff --git a/mailersend/models/__init__.py b/mailersend/models/__init__.py index 22237c5..07c8d7a 100644 --- a/mailersend/models/__init__.py +++ b/mailersend/models/__init__.py @@ -10,6 +10,11 @@ EmailRequest, EmailTrackingSettings, EmailHeader, + EmailActivityEvent, + EmailListItem, + EmailsListQueryParams, + EmailsListRequest, + EmailGetRequest, ) from .activity import ( ActivityRecipient, @@ -178,6 +183,11 @@ "EmailRequest", "EmailTrackingSettings", "EmailHeader", + "EmailActivityEvent", + "EmailListItem", + "EmailsListQueryParams", + "EmailsListRequest", + "EmailGetRequest", "ActivityRecipient", "ActivityEmail", "Activity", diff --git a/mailersend/models/email.py b/mailersend/models/email.py index 66ca351..eb3a7c1 100644 --- a/mailersend/models/email.py +++ b/mailersend/models/email.py @@ -1,6 +1,6 @@ """Email models.""" -from typing import List, Dict, Optional, Any +from typing import List, Dict, Optional, Any, Union from pydantic import ( Field, EmailStr, @@ -121,3 +121,161 @@ def validate_send_at(cls, v): if v and (v < current_time or v > current_time + 259200): raise ValueError("send_at must be between now and 72 hours from now") return v + + +EMAIL_STATUSES = {"queued", "sent", "rejected", "delivered"} + +EMAIL_INTERACTIONS = { + "opened", + "clicked", + "unsubscribed", + "complained", + "no_interaction", +} + + +class EmailActivityEvent(BaseModel): + """Model for a single activity event returned with an email.""" + + id: str + type: str # Event type (queued, sent, delivered, opened, ...) + created_at: str + # Present only for "suppressed" events: on_hold, hard_bounced, + # unsubscribed, spam_complained, blocklisted + suppression_reason: Optional[str] = None + + model_config = ConfigDict(validate_by_name=True) + + +class EmailListItem(BaseModel): + """Model for a single email row returned by the emails list endpoint.""" + + id: str + from_email: EmailStr = Field(alias="from") + to: str + # Content is never returned in list rows, only by the single email endpoint + text: Optional[str] = None + html: Optional[str] = None + subject: str + template_id: Optional[str] = None + domain_id: str + message_id: str + status: str # One of queued, sent, rejected, delivered + tags: Optional[List[str]] = None + interaction: List[str] = Field(default_factory=list) + # Only set when status is "rejected" + suppression_reason: Optional[str] = None + created_at: str + updated_at: str + headers: Optional[List[EmailHeader]] = None + + model_config = ConfigDict(validate_by_name=True) + + +class EmailsListQueryParams(BaseModel): + """ + Model for emails list query parameters with validation. + + Paginated with ``page`` and ``limit``, the same way as + ``GET /v1/activity``. + """ + + domain_id: str + date_from: Union[int, str] # Unix timestamp or datetime string + date_to: Union[int, str] # Unix timestamp or datetime string + page: Optional[int] = Field(default=1, ge=1, le=1000) + limit: Optional[int] = Field(default=25, ge=10, le=100) + status: Optional[List[str]] = None + interaction: Optional[List[str]] = None + recipient_email: Optional[EmailStr] = None + message_id: Optional[str] = None + template_id: Optional[str] = None + subject: Optional[str] = Field(default=None, min_length=3) + tag: Optional[str] = None + + model_config = ConfigDict(validate_by_name=True) + + @field_validator("domain_id") + def validate_domain_id(cls, v): + if not v or not v.strip(): + raise ValueError("domain_id is required") + return v.strip() + + @field_validator("message_id") + def validate_message_id(cls, v): + if v is not None and not v.isalnum(): + raise ValueError("message_id must be alphanumeric") + return v + + @field_validator("status") + def validate_status(cls, v): + if v: + invalid = set(v) - EMAIL_STATUSES + if invalid: + raise ValueError(f"Invalid status values: {invalid}") + return v + + @field_validator("interaction") + def validate_interaction(cls, v): + if v: + invalid = set(v) - EMAIL_INTERACTIONS + if invalid: + raise ValueError(f"Invalid interaction values: {invalid}") + return v + + def model_post_init(self, __context: Any) -> None: + """Post-initialization validation.""" + # Only comparable when both dates were given as unix timestamps + if isinstance(self.date_from, int) and isinstance(self.date_to, int): + if self.date_to <= self.date_from: + raise ValueError("date_to must be greater than date_from") + + def to_query_params(self) -> dict: + """Convert to query parameters for API request.""" + params = { + "domain_id": self.domain_id, + "date_from": self.date_from, + "date_to": self.date_to, + "page": self.page, + "limit": self.limit, + "recipient_email": self.recipient_email, + "message_id": self.message_id, + "template_id": self.template_id, + "subject": self.subject, + "tag": self.tag, + } + + # status and interaction must always be sent as arrays + for name in ("status", "interaction"): + values = getattr(self, name) + if values: + for i, value in enumerate(values): + params[f"{name}[{i}]"] = value + + return {k: v for k, v in params.items() if v is not None} + + +class EmailsListRequest(BaseModel): + """Request model for listing emails.""" + + query_params: EmailsListQueryParams + + model_config = ConfigDict(validate_by_name=True) + + def to_query_params(self) -> dict: + """Convert query parameters for API request.""" + return self.query_params.to_query_params() + + +class EmailGetRequest(BaseModel): + """Request model for getting a single email with its activity.""" + + email_id: str + + model_config = ConfigDict(validate_by_name=True) + + @field_validator("email_id") + def validate_email_id(cls, v): + if not v or not v.strip(): + raise ValueError("email_id is required") + return v.strip() diff --git a/mailersend/resources/email.py b/mailersend/resources/email.py index 5096225..cde749d 100644 --- a/mailersend/resources/email.py +++ b/mailersend/resources/email.py @@ -1,9 +1,9 @@ """Email resource""" -from typing import List +from typing import List, Union from .base import BaseResource -from ..models.email import EmailRequest +from ..models.email import EmailRequest, EmailsListRequest, EmailGetRequest from ..models.base import APIResponse @@ -73,3 +73,45 @@ def get_bulk_status(self, bulk_email_id: str) -> APIResponse: self.logger.debug("Getting bulk email status") return self._request(method="GET", path=f"bulk-email/{bulk_email_id}") + + def list(self, request: EmailsListRequest) -> APIResponse: + """ + Get a list of emails sent from a domain. + + Paginated with ``page`` and ``limit``. ``response["meta"]`` carries + ``current_page``, ``per_page``, ``from`` and ``to``, but no ``total`` + and no ``last_page`` — walk pages until ``links["next"]`` is ``None``. + + Args: + request: A fully-validated EmailsListRequest object + + Returns: + APIResponse with the emails, pagination links and metadata + """ + self.logger.debug("Preparing to list emails") + + params = request.to_query_params() + + self.logger.debug( + "Listing emails for domain: %s", request.query_params.domain_id + ) + self.logger.debug("Query params: %s", params) + + return self._request(method="GET", path="emails", params=params) + + def get(self, request: Union[EmailGetRequest, str]) -> APIResponse: + """ + Get a single email, its content and its activity events. + + Args: + request: An EmailGetRequest object, or the email ID as a string + + Returns: + APIResponse with the email and its activity events + """ + if isinstance(request, str): + request = EmailGetRequest(email_id=request) + + self.logger.debug("Getting email: %s", request.email_id) + + return self._request(method="GET", path=f"email/{request.email_id}") diff --git a/tests/unit/test_emails_builder.py b/tests/unit/test_emails_builder.py new file mode 100644 index 0000000..99bc240 --- /dev/null +++ b/tests/unit/test_emails_builder.py @@ -0,0 +1,690 @@ +import pytest +from datetime import datetime + +from mailersend.builders.email import EmailsBuilder +from mailersend.exceptions import ValidationError as MailerSendValidationError +from mailersend.models.email import EmailsListRequest, EmailGetRequest +from pydantic import ValidationError + + +def _base_builder(): + """Builder with only the required list parameters set.""" + return ( + EmailsBuilder() + .domain_id("test-domain") + .date_from(1672574400) + .date_to(1672660800) + ) + + +class TestEmailsBuilder: + """Test cases for EmailsBuilder list requests.""" + + def test_basic_builder_creation(self): + """Test basic builder instantiation.""" + builder = EmailsBuilder() + assert builder is not None + + def test_domain_id_setting(self): + """Test setting domain ID.""" + builder = EmailsBuilder() + result = builder.domain_id("test-domain") + + assert result is builder # Should return self for chaining + request = builder.date_from(1672574400).date_to(1672660800).build_list_request() + assert request.query_params.domain_id == "test-domain" + + def test_date_from_timestamp(self): + """Test setting date_from with a timestamp.""" + builder = EmailsBuilder() + result = builder.date_from(1672574400) + + assert result is builder + request = builder.domain_id("test").date_to(1672660800).build_list_request() + assert request.query_params.date_from == 1672574400 + + def test_date_from_datetime(self): + """Test setting date_from with datetime.""" + builder = EmailsBuilder() + test_date = datetime(2023, 1, 1, 12, 0, 0) + result = builder.date_from(test_date) + + assert result is builder + request = builder.domain_id("test").date_to(1972660800).build_list_request() + assert request.query_params.date_from == int(test_date.timestamp()) + + def test_date_to_timestamp(self): + """Test setting date_to with a timestamp.""" + builder = EmailsBuilder() + result = builder.date_to(1672660800) + + assert result is builder + request = builder.domain_id("test").date_from(1672574400).build_list_request() + assert request.query_params.date_to == 1672660800 + + def test_date_to_datetime(self): + """Test setting date_to with datetime.""" + builder = EmailsBuilder() + test_date = datetime(2023, 1, 2, 12, 0, 0) + result = builder.date_to(test_date) + + assert result is builder + request = builder.domain_id("test").date_from(1000000000).build_list_request() + assert request.query_params.date_to == int(test_date.timestamp()) + + def test_date_strings_are_passed_through(self): + """Test that datetime strings are forwarded unchanged.""" + request = ( + EmailsBuilder() + .domain_id("test") + .date_from("2026-08-01 00:00:00") + .date_to("2026-08-27 00:00:00") + .build_list_request() + ) + + assert request.query_params.date_from == "2026-08-01 00:00:00" + assert request.query_params.date_to == "2026-08-27 00:00:00" + + def test_page_setting(self): + """Test setting page number.""" + builder = EmailsBuilder() + result = builder.page(2) + + assert result is builder + request = _base_builder_with(builder).build_list_request() + assert request.query_params.page == 2 + + def test_limit_setting(self): + """Test setting limit.""" + builder = EmailsBuilder() + result = builder.limit(50) + + assert result is builder + request = _base_builder_with(builder).build_list_request() + assert request.query_params.limit == 50 + + def test_required_params_are_emitted(self): + """Test that the required parameters end up in the query params.""" + params = ( + _base_builder().page(3).limit(50).build_list_request().to_query_params() + ) + + assert params["domain_id"] == "test-domain" + assert params["date_from"] == 1672574400 + assert params["date_to"] == 1672660800 + assert params["page"] == 3 + assert params["limit"] == 50 + + def test_unset_page_and_limit_are_omitted(self): + """Test that page and limit are omitted when never set on the builder.""" + params = _base_builder().build_list_request().to_query_params() + + assert "page" not in params + assert "limit" not in params + assert params == { + "domain_id": "test-domain", + "date_from": 1672574400, + "date_to": 1672660800, + } + + def test_single_status_string(self): + """Test setting a single status as a string.""" + builder = EmailsBuilder() + result = builder.status("sent") + + assert result is builder + request = _base_builder_with(builder).build_list_request() + assert request.query_params.status == ["sent"] + + def test_status_list(self): + """Test setting multiple statuses as a list.""" + request = _base_builder().status(["sent", "delivered"]).build_list_request() + assert request.query_params.status == ["sent", "delivered"] + + def test_status_replaces_previous_values(self): + """Test that status() replaces any previously set values.""" + request = ( + _base_builder() + .status(["sent", "delivered"]) + .status("queued") + .build_list_request() + ) + assert request.query_params.status == ["queued"] + + def test_add_status_appends(self): + """Test that add_status appends to the status filter.""" + request = ( + _base_builder() + .status("sent") + .add_status("delivered") + .add_status("queued") + .build_list_request() + ) + assert request.query_params.status == ["sent", "delivered", "queued"] + + def test_add_status_ignores_duplicates(self): + """Test that duplicate statuses are ignored.""" + request = ( + _base_builder() + .add_status("sent") + .add_status("sent") + .add_status("delivered") + .build_list_request() + ) + assert request.query_params.status == ["sent", "delivered"] + + def test_add_status_without_status_call(self): + """Test that add_status works as the only status setter.""" + request = _base_builder().add_status("rejected").build_list_request() + assert request.query_params.status == ["rejected"] + + def test_empty_status_becomes_none(self): + """Test that an unset status filter becomes None.""" + request = _base_builder().build_list_request() + assert request.query_params.status is None + + def test_single_interaction_string(self): + """Test setting a single interaction as a string.""" + builder = EmailsBuilder() + result = builder.interaction("opened") + + assert result is builder + request = _base_builder_with(builder).build_list_request() + assert request.query_params.interaction == ["opened"] + + def test_interaction_list(self): + """Test setting multiple interactions as a list.""" + request = ( + _base_builder().interaction(["opened", "clicked"]).build_list_request() + ) + assert request.query_params.interaction == ["opened", "clicked"] + + def test_interaction_replaces_previous_values(self): + """Test that interaction() replaces any previously set values.""" + request = ( + _base_builder() + .interaction(["opened", "clicked"]) + .interaction("no_interaction") + .build_list_request() + ) + assert request.query_params.interaction == ["no_interaction"] + + def test_add_interaction_appends(self): + """Test that add_interaction appends to the interaction filter.""" + request = ( + _base_builder() + .interaction("opened") + .add_interaction("clicked") + .add_interaction("complained") + .build_list_request() + ) + assert request.query_params.interaction == [ + "opened", + "clicked", + "complained", + ] + + def test_add_interaction_ignores_duplicates(self): + """Test that duplicate interactions are ignored.""" + request = ( + _base_builder() + .add_interaction("opened") + .add_interaction("opened") + .add_interaction("clicked") + .build_list_request() + ) + assert request.query_params.interaction == ["opened", "clicked"] + + def test_empty_interaction_becomes_none(self): + """Test that an unset interaction filter becomes None.""" + request = _base_builder().build_list_request() + assert request.query_params.interaction is None + + def test_status_serialized_as_indexed_array_params(self): + """Test that status is serialized as status[0], status[1], not comma-joined.""" + params = ( + _base_builder() + .status(["sent", "delivered"]) + .build_list_request() + .to_query_params() + ) + + assert params["status[0]"] == "sent" + assert params["status[1]"] == "delivered" + # A scalar `status` is rejected by the API with a 422 + assert "status" not in params + assert "sent,delivered" not in params.values() + + def test_interaction_serialized_as_indexed_array_params(self): + """Test that interaction is serialized as interaction[0], interaction[1].""" + params = ( + _base_builder() + .interaction(["opened", "clicked"]) + .build_list_request() + .to_query_params() + ) + + assert params["interaction[0]"] == "opened" + assert params["interaction[1]"] == "clicked" + assert "interaction" not in params + assert "opened,clicked" not in params.values() + + def test_single_status_is_still_an_indexed_array_param(self): + """Test that even a single status value is sent as an indexed array param.""" + params = _base_builder().status("sent").build_list_request().to_query_params() + + assert params["status[0]"] == "sent" + assert "status" not in params + + def test_single_interaction_is_still_an_indexed_array_param(self): + """Test that even a single interaction value is sent as an indexed array.""" + params = ( + _base_builder().interaction("opened").build_list_request().to_query_params() + ) + + assert params["interaction[0]"] == "opened" + assert "interaction" not in params + + def test_all_statuses_serialized_in_order(self): + """Test that every documented status is serialized in order.""" + params = ( + _base_builder() + .status(["queued", "sent", "rejected", "delivered"]) + .build_list_request() + .to_query_params() + ) + + assert params["status[0]"] == "queued" + assert params["status[1]"] == "sent" + assert params["status[2]"] == "rejected" + assert params["status[3]"] == "delivered" + + def test_all_interactions_serialized_in_order(self): + """Test that every documented interaction is serialized in order.""" + params = ( + _base_builder() + .interaction( + [ + "opened", + "clicked", + "unsubscribed", + "complained", + "no_interaction", + ] + ) + .build_list_request() + .to_query_params() + ) + + assert params["interaction[0]"] == "opened" + assert params["interaction[1]"] == "clicked" + assert params["interaction[2]"] == "unsubscribed" + assert params["interaction[3]"] == "complained" + assert params["interaction[4]"] == "no_interaction" + + def test_recipient_email_setting(self): + """Test setting the recipient email filter.""" + builder = EmailsBuilder() + result = builder.recipient_email("rcpt@example.org") + + assert result is builder + params = _base_builder_with(builder).build_list_request().to_query_params() + assert params["recipient_email"] == "rcpt@example.org" + + def test_message_id_setting(self): + """Test setting the message ID filter.""" + builder = EmailsBuilder() + result = builder.message_id("6a8fa9b1902fab56e0ce50aa") + + assert result is builder + params = _base_builder_with(builder).build_list_request().to_query_params() + assert params["message_id"] == "6a8fa9b1902fab56e0ce50aa" + + def test_template_id_setting(self): + """Test setting the template ID filter.""" + builder = EmailsBuilder() + result = builder.template_id("7nxe3yjmeq28vp0k") + + assert result is builder + params = _base_builder_with(builder).build_list_request().to_query_params() + assert params["template_id"] == "7nxe3yjmeq28vp0k" + + def test_subject_setting(self): + """Test setting the subject filter.""" + builder = EmailsBuilder() + result = builder.subject("Welcome") + + assert result is builder + params = _base_builder_with(builder).build_list_request().to_query_params() + assert params["subject"] == "Welcome" + + def test_tag_setting(self): + """Test setting the tag filter.""" + builder = EmailsBuilder() + result = builder.tag("newsletter") + + assert result is builder + params = _base_builder_with(builder).build_list_request().to_query_params() + assert params["tag"] == "newsletter" + + def test_unset_filters_are_omitted(self): + """Test that unset optional filters are not sent.""" + params = _base_builder().build_list_request().to_query_params() + + for key in ( + "recipient_email", + "message_id", + "template_id", + "subject", + "tag", + ): + assert key not in params + + def test_method_chaining(self): + """Test method chaining works correctly.""" + builder = EmailsBuilder() + result = ( + builder.domain_id("test-domain") + .date_from(1672574400) + .date_to(1672660800) + .page(2) + .limit(50) + .status("sent") + .add_status("delivered") + .interaction("opened") + .recipient_email("rcpt@example.org") + .message_id("6a8fa9b1902fab56e0ce50aa") + .template_id("7nxe3yjmeq28vp0k") + .subject("Welcome") + .tag("newsletter") + ) + + assert result is builder + params = builder.build_list_request().to_query_params() + assert params == { + "domain_id": "test-domain", + "date_from": 1672574400, + "date_to": 1672660800, + "page": 2, + "limit": 50, + "status[0]": "sent", + "status[1]": "delivered", + "interaction[0]": "opened", + "recipient_email": "rcpt@example.org", + "message_id": "6a8fa9b1902fab56e0ce50aa", + "template_id": "7nxe3yjmeq28vp0k", + "subject": "Welcome", + "tag": "newsletter", + } + + def test_copy_builder(self): + """Test copying a builder.""" + original = _base_builder().page(2).status("sent").interaction("opened") + + copy = original.copy() + + copy_params = copy.build_list_request().to_query_params() + original_params = original.build_list_request().to_query_params() + assert copy_params == original_params + + # Verify they are independent + copy.domain_id("different-domain") + assert original.build_list_request().query_params.domain_id == "test-domain" + assert copy.build_list_request().query_params.domain_id == "different-domain" + + def test_copy_builder_does_not_share_lists(self): + """Test that copies get their own status and interaction lists.""" + original = _base_builder().status("sent").interaction("opened") + + copy = original.copy() + copy.add_status("delivered").add_interaction("clicked") + + assert original.build_list_request().query_params.status == ["sent"] + assert original.build_list_request().query_params.interaction == ["opened"] + assert copy.build_list_request().query_params.status == [ + "sent", + "delivered", + ] + assert copy.build_list_request().query_params.interaction == [ + "opened", + "clicked", + ] + + def test_copy_builder_carries_email_id(self): + """Test that copy also carries the single email ID.""" + original = EmailsBuilder().email_id("email-id") + copy = original.copy() + + assert copy.build_get_request().email_id == "email-id" + + def test_reset_builder(self): + """Test resetting a builder.""" + builder = ( + _base_builder() + .page(2) + .limit(50) + .status("sent") + .interaction("opened") + .recipient_email("rcpt@example.org") + .message_id("6a8fa9b1902fab56e0ce50aa") + .template_id("7nxe3yjmeq28vp0k") + .subject("Welcome") + .tag("newsletter") + .email_id("email-id") + ) + + # Verify builder has values + assert builder.build_list_request().query_params.page == 2 + + result = builder.reset() + assert result is builder + + # After reset we cannot build without the required fields, + # so check the internal state + assert builder._domain_id is None + assert builder._date_from is None + assert builder._date_to is None + assert builder._page is None + assert builder._limit is None + assert builder._status == [] + assert builder._interaction == [] + assert builder._recipient_email is None + assert builder._message_id is None + assert builder._template_id is None + assert builder._subject is None + assert builder._tag is None + assert builder._email_id is None + + def test_reset_allows_rebuilding(self): + """Test that a reset builder can be reused.""" + builder = _base_builder().status("sent") + builder.reset() + + params = ( + builder.domain_id("other-domain") + .date_from(1672574400) + .date_to(1672660800) + .build_list_request() + .to_query_params() + ) + + assert params == { + "domain_id": "other-domain", + "date_from": 1672574400, + "date_to": 1672660800, + } + + def test_build_list_request_returns_emails_list_request(self): + """Test that build_list_request returns an EmailsListRequest instance.""" + request = _base_builder().build_list_request() + assert isinstance(request, EmailsListRequest) + + +class TestEmailsBuilderGetRequest: + """Test cases for EmailsBuilder single email requests.""" + + def test_email_id_setting(self): + """Test setting the email ID.""" + builder = EmailsBuilder() + result = builder.email_id("6a8fa9b1902fab56e0ce50dd") + + assert result is builder # Should return self for chaining + request = builder.build_get_request() + assert request.email_id == "6a8fa9b1902fab56e0ce50dd" + + def test_build_get_request_returns_email_get_request(self): + """Test that build_get_request returns an EmailGetRequest instance.""" + request = EmailsBuilder().email_id("email-id").build_get_request() + assert isinstance(request, EmailGetRequest) + + def test_build_get_request_without_email_id_raises_error(self): + """Test that building without email_id raises ValidationError.""" + builder = EmailsBuilder() + + with pytest.raises(MailerSendValidationError) as exc_info: + builder.build_get_request() + + assert "email_id is required" in str(exc_info.value) + + def test_build_get_request_with_empty_email_id_raises_error(self): + """Test that an empty email_id raises ValidationError.""" + builder = EmailsBuilder().email_id("") + + with pytest.raises(MailerSendValidationError) as exc_info: + builder.build_get_request() + + assert "email_id is required" in str(exc_info.value) + + def test_get_request_ignores_list_params(self): + """Test that a get request only needs the email ID.""" + request = ( + EmailsBuilder().email_id("email-id").status("sent").build_get_request() + ) + assert request.email_id == "email-id" + + +class TestEmailsBuilderValidation: + """Validation test cases for EmailsBuilder list requests.""" + + def test_build_list_request_without_domain_id_raises_error(self): + """Test that a missing domain_id raises ValidationError.""" + builder = EmailsBuilder().date_from(1672574400).date_to(1672660800) + + with pytest.raises(MailerSendValidationError) as exc_info: + builder.build_list_request() + + assert "domain_id is required" in str(exc_info.value) + + def test_build_list_request_without_date_from_raises_error(self): + """Test that a missing date_from raises ValidationError.""" + builder = EmailsBuilder().domain_id("test").date_to(1672660800) + + with pytest.raises(MailerSendValidationError) as exc_info: + builder.build_list_request() + + assert "date_from is required" in str(exc_info.value) + + def test_build_list_request_without_date_to_raises_error(self): + """Test that a missing date_to raises ValidationError.""" + builder = EmailsBuilder().domain_id("test").date_from(1672574400) + + with pytest.raises(MailerSendValidationError) as exc_info: + builder.build_list_request() + + assert "date_to is required" in str(exc_info.value) + + def test_empty_domain_id_raises_error(self): + """Test that an empty domain_id raises ValidationError.""" + builder = EmailsBuilder().domain_id("").date_from(1).date_to(2) + + with pytest.raises(MailerSendValidationError) as exc_info: + builder.build_list_request() + + assert "domain_id is required" in str(exc_info.value) + + @pytest.mark.parametrize("page", [0, 1001]) + def test_invalid_page_raises_error(self, page): + """Test that a page outside 1..1000 raises an error.""" + with pytest.raises(ValidationError): + _base_builder().page(page).build_list_request() + + @pytest.mark.parametrize("page", [1, 1000]) + def test_valid_page_boundaries(self, page): + """Test that the page boundaries are accepted.""" + request = _base_builder().page(page).build_list_request() + assert request.query_params.page == page + + @pytest.mark.parametrize("limit", [9, 101]) + def test_invalid_limit_raises_error(self, limit): + """Test that a limit outside 10..100 raises an error.""" + with pytest.raises(ValidationError): + _base_builder().limit(limit).build_list_request() + + @pytest.mark.parametrize("limit", [10, 100]) + def test_valid_limit_boundaries(self, limit): + """Test that the limit boundaries are accepted.""" + request = _base_builder().limit(limit).build_list_request() + assert request.query_params.limit == limit + + def test_short_subject_raises_error(self): + """Test that a subject under 3 characters raises an error.""" + with pytest.raises(ValidationError): + _base_builder().subject("ab").build_list_request() + + def test_three_character_subject_is_valid(self): + """Test that a 3-character subject is accepted.""" + request = _base_builder().subject("abc").build_list_request() + assert request.query_params.subject == "abc" + + def test_non_alphanumeric_message_id_raises_error(self): + """Test that a non-alphanumeric message_id raises an error.""" + with pytest.raises(ValidationError, match="message_id must be alphanumeric"): + _base_builder().message_id("6a8fa9b1-902f").build_list_request() + + def test_invalid_recipient_email_raises_error(self): + """Test that an invalid recipient email raises an error.""" + with pytest.raises(ValidationError): + _base_builder().recipient_email("not-an-email").build_list_request() + + def test_invalid_status_raises_error(self): + """Test that an unknown status value raises an error.""" + with pytest.raises(ValidationError, match="Invalid status values"): + _base_builder().status(["sent", "bounced"]).build_list_request() + + def test_invalid_added_status_raises_error(self): + """Test that an unknown status added via add_status raises an error.""" + with pytest.raises(ValidationError, match="Invalid status values"): + _base_builder().add_status("opened").build_list_request() + + def test_invalid_interaction_raises_error(self): + """Test that an unknown interaction value raises an error.""" + with pytest.raises(ValidationError, match="Invalid interaction values"): + _base_builder().interaction(["opened", "bounced"]).build_list_request() + + def test_invalid_added_interaction_raises_error(self): + """Test that an unknown interaction via add_interaction raises an error.""" + with pytest.raises(ValidationError, match="Invalid interaction values"): + _base_builder().add_interaction("sent").build_list_request() + + def test_date_to_equal_to_date_from_raises_error(self): + """Test that date_to equal to date_from raises an error.""" + with pytest.raises( + ValidationError, match="date_to must be greater than date_from" + ): + EmailsBuilder().domain_id("test").date_from(1672574400).date_to( + 1672574400 + ).build_list_request() + + def test_date_to_before_date_from_raises_error(self): + """Test that date_to earlier than date_from raises an error.""" + with pytest.raises( + ValidationError, match="date_to must be greater than date_from" + ): + EmailsBuilder().domain_id("test").date_from(1672660800).date_to( + 1672574400 + ).build_list_request() + + +def _base_builder_with(builder: EmailsBuilder) -> EmailsBuilder: + """Add the required list parameters to an existing builder.""" + return builder.domain_id("test").date_from(1672574400).date_to(1672660800) diff --git a/tests/unit/test_emails_models.py b/tests/unit/test_emails_models.py new file mode 100644 index 0000000..0ab3f1c --- /dev/null +++ b/tests/unit/test_emails_models.py @@ -0,0 +1,686 @@ +import pytest +from pydantic import ValidationError + +from mailersend.models.email import ( + EmailActivityEvent, + EmailHeader, + EmailListItem, + EmailsListQueryParams, + EmailsListRequest, + EmailGetRequest, +) + + +# Measured response for a populated page of GET /v1/emails +EMAILS_LIST_RESPONSE = { + "data": [ + { + "id": "6a8fa9b1902fab56e0ce50dd", + "from": "sender@example.com", + "to": "rcpt@example.org", + "subject": "Welcome", + "text": None, + "html": None, + "template_id": "7nxe3yjmeq28vp0k", + "domain_id": "7nxe3yjmeq28vp0k", + "message_id": "6a8fa9b1902fab56e0ce50aa", + "status": "sent", + "tags": ["newsletter"], + "interaction": ["opened"], + "suppression_reason": None, + "created_at": "2026-08-27T16:48:42.000000Z", + "updated_at": "2026-08-27T16:48:42.000000Z", + "headers": [{"name": "X-Custom", "value": "foo"}], + } + ], + "links": { + "first": "https://api.mailersend.com/v1/emails?page=1", + "last": None, + "prev": None, + "next": None, + }, + "meta": { + "current_page": 1, + "current_page_url": "https://api.mailersend.com/v1/emails?page=1", + "from": 1, + "path": "https://api.mailersend.com/v1/emails", + "per_page": 10, + "to": 3, + }, +} + +# Measured response for an empty page of GET /v1/emails +EMAILS_LIST_EMPTY_RESPONSE = { + "data": [], + "links": { + "first": "https://api.mailersend.com/v1/emails?page=1", + "last": None, + "prev": "https://api.mailersend.com/v1/emails?page=1", + "next": None, + }, + "meta": { + "current_page": 2, + "current_page_url": "https://api.mailersend.com/v1/emails?page=2", + "from": None, + "path": "https://api.mailersend.com/v1/emails", + "per_page": 10, + "to": None, + }, +} + +# Measured response for GET /v1/email/{email_id}: the list row plus +# a `recipient` object and an `activity` array +EMAIL_GET_RESPONSE = { + "data": { + **EMAILS_LIST_RESPONSE["data"][0], + "recipient": { + "id": "6a8fa9b1902fab56e0ce50cc", + "email": "rcpt@example.org", + "created_at": "2026-08-27T16:48:42.000000Z", + "updated_at": "2026-08-27T16:48:42.000000Z", + }, + "activity": [ + { + "id": "6a8fa9b1902fab56e0ce50e1", + "type": "queued", + "created_at": "2026-08-27T16:48:42.000000Z", + }, + { + "id": "6a8fa9b1902fab56e0ce50e2", + "type": "sent", + "created_at": "2026-08-27T16:48:43.000000Z", + }, + { + "id": "6a8fa9b1902fab56e0ce50e3", + "type": "opened", + "created_at": "2026-08-27T16:49:17.000000Z", + }, + ], + } +} + + +class TestEmailActivityEvent: + def test_valid_event(self): + event = EmailActivityEvent( + id="6a8fa9b1902fab56e0ce50e1", + type="opened", + created_at="2026-08-27T16:48:42.000000Z", + ) + + assert event.id == "6a8fa9b1902fab56e0ce50e1" + assert event.type == "opened" + assert event.created_at == "2026-08-27T16:48:42.000000Z" + assert event.suppression_reason is None + + def test_event_with_suppression_reason(self): + event = EmailActivityEvent( + id="6a8fa9b1902fab56e0ce50e1", + type="suppressed", + created_at="2026-08-27T16:48:42.000000Z", + suppression_reason="hard_bounced", + ) + + assert event.type == "suppressed" + assert event.suppression_reason == "hard_bounced" + + def test_required_fields(self): + with pytest.raises(ValidationError) as exc_info: + EmailActivityEvent() + + errors = exc_info.value.errors() + required_fields = {"id", "type", "created_at"} + error_fields = {error["loc"][0] for error in errors} + assert required_fields.issubset(error_fields) + + def test_parses_measured_activity_array(self): + """Test that the measured activity array parses into events.""" + events = [ + EmailActivityEvent(**event) + for event in EMAIL_GET_RESPONSE["data"]["activity"] + ] + + assert len(events) == 3 + assert [event.type for event in events] == ["queued", "sent", "opened"] + assert all(event.suppression_reason is None for event in events) + + +class TestEmailListItem: + def test_parses_measured_row(self): + """Test that the measured list row parses in full.""" + row = EMAILS_LIST_RESPONSE["data"][0] + item = EmailListItem(**row) + + assert item.id == "6a8fa9b1902fab56e0ce50dd" + assert item.from_email == "sender@example.com" + assert item.to == "rcpt@example.org" + assert item.subject == "Welcome" + assert item.text is None + assert item.html is None + assert item.template_id == "7nxe3yjmeq28vp0k" + assert item.domain_id == "7nxe3yjmeq28vp0k" + assert item.message_id == "6a8fa9b1902fab56e0ce50aa" + assert item.status == "sent" + assert item.tags == ["newsletter"] + assert item.interaction == ["opened"] + assert item.suppression_reason is None + assert item.created_at == "2026-08-27T16:48:42.000000Z" + assert item.updated_at == "2026-08-27T16:48:42.000000Z" + + def test_from_field_is_aliased_to_from_email(self): + """Test that the response's 'from' key populates from_email.""" + item = EmailListItem(**EMAILS_LIST_RESPONSE["data"][0]) + + assert item.from_email == "sender@example.com" + + def test_from_email_can_also_be_populated_by_field_name(self): + """Test that from_email is accepted by its field name too.""" + row = dict(EMAILS_LIST_RESPONSE["data"][0]) + row.pop("from") + row["from_email"] = "sender@example.com" + + item = EmailListItem(**row) + + assert item.from_email == "sender@example.com" + + def test_headers_are_parsed_as_objects(self): + """Test that headers become EmailHeader objects with name and value.""" + item = EmailListItem(**EMAILS_LIST_RESPONSE["data"][0]) + + assert len(item.headers) == 1 + assert isinstance(item.headers[0], EmailHeader) + assert item.headers[0].name == "X-Custom" + assert item.headers[0].value == "foo" + + def test_missing_headers_defaults_to_none(self): + """Test that an absent headers key leaves headers as None.""" + row = dict(EMAILS_LIST_RESPONSE["data"][0]) + row.pop("headers") + + item = EmailListItem(**row) + + assert item.headers is None + + def test_empty_interaction_list(self): + """Test that an email with no interactions parses to an empty list.""" + row = dict(EMAILS_LIST_RESPONSE["data"][0]) + row["interaction"] = [] + + item = EmailListItem(**row) + + assert item.interaction == [] + + def test_missing_interaction_defaults_to_empty_list(self): + """Test that an absent interaction key defaults to an empty list.""" + row = dict(EMAILS_LIST_RESPONSE["data"][0]) + row.pop("interaction") + + item = EmailListItem(**row) + + assert item.interaction == [] + + def test_rejected_email_with_suppression_reason(self): + """Test that suppression_reason is retained for rejected emails.""" + row = dict(EMAILS_LIST_RESPONSE["data"][0]) + row["status"] = "rejected" + row["suppression_reason"] = "hard_bounced" + + item = EmailListItem(**row) + + assert item.status == "rejected" + assert item.suppression_reason == "hard_bounced" + + def test_optional_fields_default_to_none(self): + """Test that the optional fields default to None when absent.""" + item = EmailListItem( + id="6a8fa9b1902fab56e0ce50dd", + **{"from": "sender@example.com"}, + to="rcpt@example.org", + subject="Welcome", + domain_id="7nxe3yjmeq28vp0k", + message_id="6a8fa9b1902fab56e0ce50aa", + status="sent", + created_at="2026-08-27T16:48:42.000000Z", + updated_at="2026-08-27T16:48:42.000000Z", + ) + + assert item.text is None + assert item.html is None + assert item.template_id is None + assert item.tags is None + assert item.suppression_reason is None + assert item.headers is None + assert item.interaction == [] + + def test_content_is_returned_for_a_single_email(self): + """Test that text and html are parsed when the API returns them.""" + row = dict(EMAILS_LIST_RESPONSE["data"][0]) + row["text"] = "Hello" + row["html"] = "

Hello

" + + item = EmailListItem(**row) + + assert item.text == "Hello" + assert item.html == "

Hello

" + + def test_required_fields(self): + with pytest.raises(ValidationError) as exc_info: + EmailListItem() + + errors = exc_info.value.errors() + required_fields = { + "id", + "from", + "to", + "subject", + "domain_id", + "message_id", + "status", + "created_at", + "updated_at", + } + error_fields = {error["loc"][0] for error in errors} + assert required_fields.issubset(error_fields) + + def test_invalid_from_email(self): + row = dict(EMAILS_LIST_RESPONSE["data"][0]) + row["from"] = "invalid-email" + + with pytest.raises(ValidationError) as exc_info: + EmailListItem(**row) + + errors = exc_info.value.errors() + assert any(error["loc"] == ("from",) for error in errors) + + def test_single_email_extra_keys_are_ignored(self): + """The single email row carries recipient and activity, which have no + fields on EmailListItem and are dropped rather than rejected.""" + item = EmailListItem(**EMAIL_GET_RESPONSE["data"]) + + assert item.id == "6a8fa9b1902fab56e0ce50dd" + assert not hasattr(item, "recipient") + assert not hasattr(item, "activity") + + def test_parses_every_row_of_a_measured_page(self): + items = [EmailListItem(**row) for row in EMAILS_LIST_RESPONSE["data"]] + + assert len(items) == 1 + assert items[0].from_email == "sender@example.com" + + def test_parses_an_empty_measured_page(self): + items = [EmailListItem(**row) for row in EMAILS_LIST_EMPTY_RESPONSE["data"]] + + assert items == [] + + +class TestEmailsListQueryParams: + def test_valid_params(self): + params = EmailsListQueryParams( + domain_id="7nxe3yjmeq28vp0k", + date_from=1672574400, + date_to=1672660800, + page=2, + limit=50, + status=["sent", "delivered"], + interaction=["opened"], + recipient_email="rcpt@example.org", + message_id="6a8fa9b1902fab56e0ce50aa", + template_id="7nxe3yjmeq28vp0k", + subject="Welcome", + tag="newsletter", + ) + + assert params.domain_id == "7nxe3yjmeq28vp0k" + assert params.date_from == 1672574400 + assert params.date_to == 1672660800 + assert params.page == 2 + assert params.limit == 50 + assert params.status == ["sent", "delivered"] + assert params.interaction == ["opened"] + assert params.recipient_email == "rcpt@example.org" + assert params.message_id == "6a8fa9b1902fab56e0ce50aa" + assert params.template_id == "7nxe3yjmeq28vp0k" + assert params.subject == "Welcome" + assert params.tag == "newsletter" + + def test_default_values(self): + params = EmailsListQueryParams( + domain_id="7nxe3yjmeq28vp0k", + date_from=1672574400, + date_to=1672660800, + ) + + assert params.page == 1 + assert params.limit == 25 + assert params.status is None + assert params.interaction is None + assert params.recipient_email is None + assert params.message_id is None + assert params.template_id is None + assert params.subject is None + assert params.tag is None + + def test_domain_id_is_stripped(self): + params = EmailsListQueryParams( + domain_id=" 7nxe3yjmeq28vp0k ", + date_from=1672574400, + date_to=1672660800, + ) + + assert params.domain_id == "7nxe3yjmeq28vp0k" + + def test_required_fields(self): + with pytest.raises(ValidationError) as exc_info: + EmailsListQueryParams() + + errors = exc_info.value.errors() + required_fields = {"domain_id", "date_from", "date_to"} + error_fields = {error["loc"][0] for error in errors} + assert required_fields.issubset(error_fields) + + @pytest.mark.parametrize("domain_id", ["", " "]) + def test_empty_domain_id_raises(self, domain_id): + with pytest.raises(ValidationError, match="domain_id is required"): + EmailsListQueryParams( + domain_id=domain_id, date_from=1672574400, date_to=1672660800 + ) + + @pytest.mark.parametrize("page", [0, 1001]) + def test_page_validation(self, page): + with pytest.raises(ValidationError): + EmailsListQueryParams( + domain_id="domain", + date_from=1672574400, + date_to=1672660800, + page=page, + ) + + @pytest.mark.parametrize("limit", [9, 101]) + def test_limit_validation(self, limit): + with pytest.raises(ValidationError): + EmailsListQueryParams( + domain_id="domain", + date_from=1672574400, + date_to=1672660800, + limit=limit, + ) + + def test_subject_min_length_validation(self): + with pytest.raises(ValidationError): + EmailsListQueryParams( + domain_id="domain", + date_from=1672574400, + date_to=1672660800, + subject="ab", + ) + + def test_message_id_must_be_alphanumeric(self): + with pytest.raises(ValidationError, match="message_id must be alphanumeric"): + EmailsListQueryParams( + domain_id="domain", + date_from=1672574400, + date_to=1672660800, + message_id="6a8fa9b1-902f", + ) + + def test_status_validation(self): + params = EmailsListQueryParams( + domain_id="domain", + date_from=1672574400, + date_to=1672660800, + status=["queued", "sent", "rejected", "delivered"], + ) + assert params.status == ["queued", "sent", "rejected", "delivered"] + + with pytest.raises(ValidationError, match="Invalid status values"): + EmailsListQueryParams( + domain_id="domain", + date_from=1672574400, + date_to=1672660800, + status=["sent", "opened"], + ) + + def test_interaction_validation(self): + all_interactions = [ + "opened", + "clicked", + "unsubscribed", + "complained", + "no_interaction", + ] + params = EmailsListQueryParams( + domain_id="domain", + date_from=1672574400, + date_to=1672660800, + interaction=all_interactions, + ) + assert params.interaction == all_interactions + + with pytest.raises(ValidationError, match="Invalid interaction values"): + EmailsListQueryParams( + domain_id="domain", + date_from=1672574400, + date_to=1672660800, + interaction=["opened", "sent"], + ) + + def test_invalid_recipient_email_raises(self): + with pytest.raises(ValidationError): + EmailsListQueryParams( + domain_id="domain", + date_from=1672574400, + date_to=1672660800, + recipient_email="not-an-email", + ) + + def test_date_range_validation(self): + with pytest.raises( + ValidationError, match="date_to must be greater than date_from" + ): + EmailsListQueryParams( + domain_id="domain", date_from=1672660800, date_to=1672574400 + ) + + with pytest.raises( + ValidationError, match="date_to must be greater than date_from" + ): + EmailsListQueryParams( + domain_id="domain", date_from=1672574400, date_to=1672574400 + ) + + def test_date_range_validation_skipped_for_string_dates(self): + """Dates given as strings are not comparable and are left to the API.""" + params = EmailsListQueryParams( + domain_id="domain", + date_from="2026-08-27 00:00:00", + date_to="2026-08-01 00:00:00", + ) + + assert params.date_from == "2026-08-27 00:00:00" + assert params.date_to == "2026-08-01 00:00:00" + + def test_to_query_params(self): + params = EmailsListQueryParams( + domain_id="7nxe3yjmeq28vp0k", + date_from=1672574400, + date_to=1672660800, + page=2, + limit=50, + status=["sent", "delivered"], + interaction=["opened", "clicked"], + recipient_email="rcpt@example.org", + message_id="6a8fa9b1902fab56e0ce50aa", + template_id="7nxe3yjmeq28vp0k", + subject="Welcome", + tag="newsletter", + ) + + assert params.to_query_params() == { + "domain_id": "7nxe3yjmeq28vp0k", + "date_from": 1672574400, + "date_to": 1672660800, + "page": 2, + "limit": 50, + "status[0]": "sent", + "status[1]": "delivered", + "interaction[0]": "opened", + "interaction[1]": "clicked", + "recipient_email": "rcpt@example.org", + "message_id": "6a8fa9b1902fab56e0ce50aa", + "template_id": "7nxe3yjmeq28vp0k", + "subject": "Welcome", + "tag": "newsletter", + } + + def test_to_query_params_minimal(self): + params = EmailsListQueryParams( + domain_id="7nxe3yjmeq28vp0k", + date_from=1672574400, + date_to=1672660800, + ) + + assert params.to_query_params() == { + "domain_id": "7nxe3yjmeq28vp0k", + "date_from": 1672574400, + "date_to": 1672660800, + "page": 1, + "limit": 25, + } + + def test_to_query_params_omits_none_values(self): + params = EmailsListQueryParams( + domain_id="7nxe3yjmeq28vp0k", + date_from=1672574400, + date_to=1672660800, + page=None, + limit=None, + ) + + assert params.to_query_params() == { + "domain_id": "7nxe3yjmeq28vp0k", + "date_from": 1672574400, + "date_to": 1672660800, + } + + def test_status_is_serialized_as_indexed_array_params(self): + """status must be sent as status[0], status[1] — never comma-joined, + which the API rejects with a 422.""" + params = EmailsListQueryParams( + domain_id="domain", + date_from=1672574400, + date_to=1672660800, + status=["queued", "sent", "rejected", "delivered"], + ) + + query_params = params.to_query_params() + + assert query_params["status[0]"] == "queued" + assert query_params["status[1]"] == "sent" + assert query_params["status[2]"] == "rejected" + assert query_params["status[3]"] == "delivered" + assert "status" not in query_params + assert not any( + isinstance(value, str) and "," in value for value in query_params.values() + ) + + def test_interaction_is_serialized_as_indexed_array_params(self): + """interaction must be sent as interaction[0], interaction[1], ...""" + params = EmailsListQueryParams( + domain_id="domain", + date_from=1672574400, + date_to=1672660800, + interaction=["opened", "clicked", "no_interaction"], + ) + + query_params = params.to_query_params() + + assert query_params["interaction[0]"] == "opened" + assert query_params["interaction[1]"] == "clicked" + assert query_params["interaction[2]"] == "no_interaction" + assert "interaction" not in query_params + + def test_empty_status_and_interaction_lists_are_omitted(self): + params = EmailsListQueryParams( + domain_id="domain", + date_from=1672574400, + date_to=1672660800, + status=[], + interaction=[], + ) + + query_params = params.to_query_params() + + assert "status" not in query_params + assert "status[0]" not in query_params + assert "interaction" not in query_params + assert "interaction[0]" not in query_params + + +class TestEmailsListRequest: + def test_valid_request(self): + query_params = EmailsListQueryParams( + domain_id="7nxe3yjmeq28vp0k", + date_from=1672574400, + date_to=1672660800, + ) + + request = EmailsListRequest(query_params=query_params) + + assert request.query_params == query_params + + def test_to_query_params_delegates_to_query_params(self): + query_params = EmailsListQueryParams( + domain_id="7nxe3yjmeq28vp0k", + date_from=1672574400, + date_to=1672660800, + status=["sent"], + ) + + request = EmailsListRequest(query_params=query_params) + + assert request.to_query_params() == query_params.to_query_params() + assert request.to_query_params()["status[0]"] == "sent" + + def test_required_fields(self): + with pytest.raises(ValidationError) as exc_info: + EmailsListRequest() + + errors = exc_info.value.errors() + error_fields = {error["loc"][0] for error in errors} + assert {"query_params"}.issubset(error_fields) + + +class TestEmailGetRequest: + def test_valid_email_id(self): + request = EmailGetRequest(email_id="6a8fa9b1902fab56e0ce50dd") + + assert request.email_id == "6a8fa9b1902fab56e0ce50dd" + + def test_email_id_with_whitespace(self): + request = EmailGetRequest(email_id=" 6a8fa9b1902fab56e0ce50dd ") + + assert request.email_id == "6a8fa9b1902fab56e0ce50dd" + + def test_empty_email_id(self): + with pytest.raises(ValueError) as exc_info: + EmailGetRequest(email_id="") + + assert "email_id is required" in str(exc_info.value) + + def test_whitespace_only_email_id(self): + with pytest.raises(ValueError) as exc_info: + EmailGetRequest(email_id=" ") + + assert "email_id is required" in str(exc_info.value) + + def test_none_email_id(self): + with pytest.raises(ValueError): + EmailGetRequest(email_id=None) + + def test_required_fields(self): + with pytest.raises(ValidationError) as exc_info: + EmailGetRequest() + + errors = exc_info.value.errors() + error_fields = {error["loc"][0] for error in errors} + assert {"email_id"}.issubset(error_fields) diff --git a/tests/unit/test_emails_resource.py b/tests/unit/test_emails_resource.py new file mode 100644 index 0000000..f7b051e --- /dev/null +++ b/tests/unit/test_emails_resource.py @@ -0,0 +1,270 @@ +"""Tests for the emails list and get requests on the Email resource.""" + +import inspect + +from unittest.mock import AsyncMock, MagicMock, Mock +import pytest + +from mailersend.resources.email import Email +from mailersend.builders.email import EmailsBuilder +from mailersend.models.email import ( + EmailsListRequest, + EmailsListQueryParams, + EmailGetRequest, +) +from mailersend.models.base import APIResponse + + +async def resolve(result): + if inspect.iscoroutine(result): + return await result + return result + + +# Measured response for a populated page of GET /v1/emails +EMAILS_LIST_RESPONSE = { + "data": [ + { + "id": "6a8fa9b1902fab56e0ce50dd", + "from": "sender@example.com", + "to": "rcpt@example.org", + "subject": "Welcome", + "text": None, + "html": None, + "template_id": "7nxe3yjmeq28vp0k", + "domain_id": "7nxe3yjmeq28vp0k", + "message_id": "6a8fa9b1902fab56e0ce50aa", + "status": "sent", + "tags": ["newsletter"], + "interaction": ["opened"], + "suppression_reason": None, + "created_at": "2026-08-27T16:48:42.000000Z", + "updated_at": "2026-08-27T16:48:42.000000Z", + "headers": [{"name": "X-Custom", "value": "foo"}], + } + ], + "links": { + "first": "https://api.mailersend.com/v1/emails?page=1", + "last": None, + "prev": None, + "next": None, + }, + "meta": { + "current_page": 1, + "current_page_url": "https://api.mailersend.com/v1/emails?page=1", + "from": 1, + "path": "https://api.mailersend.com/v1/emails", + "per_page": 10, + "to": 3, + }, +} + +# Measured response for an empty page of GET /v1/emails +EMAILS_LIST_EMPTY_RESPONSE = { + "data": [], + "links": { + "first": "https://api.mailersend.com/v1/emails?page=1", + "last": None, + "prev": "https://api.mailersend.com/v1/emails?page=1", + "next": None, + }, + "meta": { + "current_page": 2, + "current_page_url": "https://api.mailersend.com/v1/emails?page=2", + "from": None, + "path": "https://api.mailersend.com/v1/emails", + "per_page": 10, + "to": None, + }, +} + + +def _make_mock_response(json_data=None): + response = MagicMock() + response.status_code = 200 + response.headers = {"x-request-id": "test-req-id"} + response.json.return_value = json_data if json_data is not None else {} + response.content = b"{}" + return response + + +def _list_request(**overrides): + params = { + "domain_id": "7nxe3yjmeq28vp0k", + "date_from": 1672574400, + "date_to": 1672660800, + } + params.update(overrides) + return EmailsListRequest(query_params=EmailsListQueryParams(**params)) + + +class TestEmailsListAndGet: + @pytest.fixture(autouse=True, params=["sync", "async"]) + def setup(self, request): + if request.param == "async": + self.mock_client = MagicMock() + self.mock_client.request = AsyncMock(return_value=_make_mock_response()) + else: + self.mock_client = MagicMock() + self.mock_client.request = Mock(return_value=_make_mock_response()) + self.resource = Email(self.mock_client) + + async def test_list_returns_api_response(self): + result = await resolve(self.resource.list(_list_request())) + assert isinstance(result, APIResponse) + + async def test_list_calls_correct_endpoint(self): + await resolve(self.resource.list(_list_request())) + + call = self.mock_client.request.call_args + assert call.kwargs["method"] == "GET" + assert call.kwargs["path"] == "emails" + + async def test_list_passes_query_params(self): + await resolve(self.resource.list(_list_request(page=2, limit=50))) + + call = self.mock_client.request.call_args + assert call.kwargs["params"] == { + "domain_id": "7nxe3yjmeq28vp0k", + "date_from": 1672574400, + "date_to": 1672660800, + "page": 2, + "limit": 50, + } + + async def test_list_sends_status_as_indexed_array_params(self): + """The API rejects a scalar `status` with a 422, so the resource must + send status[0], status[1], ... instead of a comma-joined value.""" + request = _list_request(status=["sent", "delivered"], interaction=["opened"]) + + await resolve(self.resource.list(request)) + + params = self.mock_client.request.call_args.kwargs["params"] + assert params["status[0]"] == "sent" + assert params["status[1]"] == "delivered" + assert params["interaction[0]"] == "opened" + assert "status" not in params + assert "interaction" not in params + + async def test_list_sends_builder_query_params(self): + request = ( + EmailsBuilder() + .domain_id("7nxe3yjmeq28vp0k") + .date_from(1672574400) + .date_to(1672660800) + .status("sent") + .add_status("delivered") + .interaction("opened") + .build_list_request() + ) + + await resolve(self.resource.list(request)) + + params = self.mock_client.request.call_args.kwargs["params"] + assert params == { + "domain_id": "7nxe3yjmeq28vp0k", + "date_from": 1672574400, + "date_to": 1672660800, + "status[0]": "sent", + "status[1]": "delivered", + "interaction[0]": "opened", + } + + async def test_list_surfaces_measured_page(self): + self.mock_client.request.return_value = _make_mock_response( + EMAILS_LIST_RESPONSE + ) + + result = await resolve(self.resource.list(_list_request())) + + assert len(result["data"]) == 1 + assert result["data"][0]["id"] == "6a8fa9b1902fab56e0ce50dd" + assert result["data"][0]["from"] == "sender@example.com" + assert result["links"]["next"] is None + assert result["meta"]["current_page"] == 1 + assert result["meta"]["per_page"] == 10 + + async def test_list_surfaces_measured_empty_page(self): + self.mock_client.request.return_value = _make_mock_response( + EMAILS_LIST_EMPTY_RESPONSE + ) + + result = await resolve(self.resource.list(_list_request(page=2))) + + assert result["data"] == [] + assert result["links"]["prev"] is not None + assert result["links"]["next"] is None + assert result["meta"]["from"] is None + assert result["meta"]["to"] is None + + async def test_get_returns_api_response(self): + result = await resolve(self.resource.get(EmailGetRequest(email_id="email123"))) + assert isinstance(result, APIResponse) + + async def test_get_calls_singular_email_endpoint(self): + await resolve(self.resource.get(EmailGetRequest(email_id="email123"))) + + call = self.mock_client.request.call_args + assert call.kwargs["method"] == "GET" + assert call.kwargs["path"] == "email/email123" + + async def test_get_accepts_a_bare_id_string(self): + result = await resolve(self.resource.get("email123")) + + assert isinstance(result, APIResponse) + call = self.mock_client.request.call_args + assert call.kwargs["method"] == "GET" + assert call.kwargs["path"] == "email/email123" + + async def test_get_strips_whitespace_from_a_bare_id_string(self): + await resolve(self.resource.get(" email123 ")) + + call = self.mock_client.request.call_args + assert call.kwargs["path"] == "email/email123" + + async def test_get_accepts_a_built_request(self): + request = EmailsBuilder().email_id("email123").build_get_request() + + await resolve(self.resource.get(request)) + + call = self.mock_client.request.call_args + assert call.kwargs["path"] == "email/email123" + + async def test_get_sends_no_query_params(self): + await resolve(self.resource.get("email123")) + + assert "params" not in self.mock_client.request.call_args.kwargs + + async def test_get_surfaces_activity_events(self): + payload = { + "data": { + **EMAILS_LIST_RESPONSE["data"][0], + "recipient": { + "id": "6a8fa9b1902fab56e0ce50cc", + "email": "rcpt@example.org", + "created_at": "2026-08-27T16:48:42.000000Z", + "updated_at": "2026-08-27T16:48:42.000000Z", + }, + "activity": [ + { + "id": "6a8fa9b1902fab56e0ce50e1", + "type": "queued", + "created_at": "2026-08-27T16:48:42.000000Z", + }, + { + "id": "6a8fa9b1902fab56e0ce50e2", + "type": "sent", + "created_at": "2026-08-27T16:48:43.000000Z", + }, + ], + } + } + self.mock_client.request.return_value = _make_mock_response(payload) + + result = await resolve(self.resource.get("email123")) + + assert result["data"]["recipient"]["email"] == "rcpt@example.org" + assert [event["type"] for event in result["data"]["activity"]] == [ + "queued", + "sent", + ]