diff --git a/README.md b/README.md index 2666f1f..1fa10cd 100644 --- a/README.md +++ b/README.md @@ -182,6 +182,16 @@ MailerSend Python SDK - [Get a single invite](#get-a-single-invite) - [Resend an invite](#resend-an-invite) - [Cancel an invite](#cancel-an-invite) + - [DMARC Monitoring](#dmarc-monitoring) + - [Get a list of monitors](#get-a-list-of-monitors) + - [Create a monitor](#create-a-monitor) + - [Update a monitor](#update-a-monitor) + - [Delete a monitor](#delete-a-monitor) + - [Get aggregated reports](#get-aggregated-reports) + - [Get IP-specific reports](#get-ip-specific-reports) + - [Get report sources](#get-report-sources) + - [Mark IP as favorite](#mark-ip-as-favorite) + - [Remove IP from favorites](#remove-ip-from-favorites) - [Other Endpoints](#other-endpoints) - [Get API Quota](#get-api-quota) - [Error Handling](#error-handling) @@ -2681,6 +2691,143 @@ request = (UsersBuilder() response = ms.users.cancel_invite(request) ``` +## DMARC Monitoring + +### Get a list of monitors + +```python +from mailersend import MailerSendClient, DmarcMonitoringBuilder + +ms = MailerSendClient() + +request = (DmarcMonitoringBuilder() + .page(1) + .limit(25) + .build_list_request()) + +response = ms.dmarc_monitoring.list_monitors(request) +``` + +### Create a monitor + +```python +from mailersend import MailerSendClient, DmarcMonitoringBuilder + +ms = MailerSendClient() + +request = (DmarcMonitoringBuilder() + .domain_id("your-domain-id") + .build_create_request()) + +response = ms.dmarc_monitoring.create_monitor(request) +``` + +### Update a monitor + +```python +from mailersend import MailerSendClient, DmarcMonitoringBuilder + +ms = MailerSendClient() + +request = (DmarcMonitoringBuilder() + .monitor_id("monitor-id") + .wanted_dmarc_record("v=DMARC1; p=reject; rua=mailto:dmarc@example.com") + .build_update_request()) + +response = ms.dmarc_monitoring.update_monitor(request) +``` + +### Delete a monitor + +```python +from mailersend import MailerSendClient, DmarcMonitoringBuilder + +ms = MailerSendClient() + +request = (DmarcMonitoringBuilder() + .monitor_id("monitor-id") + .build_delete_request()) + +response = ms.dmarc_monitoring.delete_monitor(request) +``` + +### Get aggregated reports + +```python +from mailersend import MailerSendClient, DmarcMonitoringBuilder + +ms = MailerSendClient() + +request = (DmarcMonitoringBuilder() + .monitor_id("monitor-id") + .page(1) + .limit(25) + .build_report_request()) + +response = ms.dmarc_monitoring.get_aggregated_report(request) +``` + +### Get IP-specific reports + +```python +from mailersend import MailerSendClient, DmarcMonitoringBuilder + +ms = MailerSendClient() + +request = (DmarcMonitoringBuilder() + .monitor_id("monitor-id") + .ip("192.168.1.1") + .page(1) + .limit(25) + .build_ip_report_request()) + +response = ms.dmarc_monitoring.get_ip_report(request) +``` + +### Get report sources + +```python +from mailersend import MailerSendClient, DmarcMonitoringBuilder + +ms = MailerSendClient() + +request = (DmarcMonitoringBuilder() + .monitor_id("monitor-id") + .build_report_sources_request()) + +response = ms.dmarc_monitoring.get_report_sources(request) +``` + +### Mark IP as favorite + +```python +from mailersend import MailerSendClient, DmarcMonitoringBuilder + +ms = MailerSendClient() + +request = (DmarcMonitoringBuilder() + .monitor_id("monitor-id") + .ip("192.168.1.1") + .build_mark_favorite_request()) + +response = ms.dmarc_monitoring.mark_ip_favorite(request) +``` + +### Remove IP from favorites + +```python +from mailersend import MailerSendClient, DmarcMonitoringBuilder + +ms = MailerSendClient() + +request = (DmarcMonitoringBuilder() + .monitor_id("monitor-id") + .ip("192.168.1.1") + .build_remove_favorite_request()) + +response = ms.dmarc_monitoring.remove_ip_favorite(request) +``` + ## Other Endpoints ### Get API Quota @@ -2799,6 +2946,7 @@ def test_list_sms_recipients(): | SMS Inbound Routing | `{GET, POST, PUT, DELETE} sms-inbounds` | ✅ | | Sender Identities | `{GET, POST, PUT, DELETE} identities` | ✅ | | API Quota | `GET api-quota` | ✅ | +| DMARC Monitoring | `{GET, POST, PUT, DELETE} dmarc-monitoring` | ✅ | *All endpoints are available and fully tested. Refer to [official API docs](https://developers.mailersend.com/) for the most up-to-date API specifications.* diff --git a/mailersend/__init__.py b/mailersend/__init__.py index dd10077..0a9d061 100644 --- a/mailersend/__init__.py +++ b/mailersend/__init__.py @@ -29,6 +29,7 @@ from .builders.sms_recipients import SmsRecipientsBuilder from .builders.sms_webhooks import SmsWebhooksBuilder from .builders.sms_inbounds import SmsInboundsBuilder +from .builders.dmarc_monitoring import DmarcMonitoringBuilder from .resources.email import Email from .resources.activity import Activity from .resources.analytics import Analytics @@ -91,7 +92,8 @@ "SmsRecipientsBuilder", "SmsWebhooksBuilder", "SmsInboundsBuilder", - + "DmarcMonitoringBuilder", + # Resources "Email", "Activity", diff --git a/mailersend/builders/__init__.py b/mailersend/builders/__init__.py index 53bb973..75db6d0 100644 --- a/mailersend/builders/__init__.py +++ b/mailersend/builders/__init__.py @@ -26,6 +26,7 @@ from .sms_recipients import SmsRecipientsBuilder from .sms_webhooks import SmsWebhooksBuilder from .sms_inbounds import SmsInboundsBuilder +from .dmarc_monitoring import DmarcMonitoringBuilder __all__ = [ "EmailBuilder", @@ -50,4 +51,5 @@ "SmsRecipientsBuilder", "SmsWebhooksBuilder", "SmsInboundsBuilder", + "DmarcMonitoringBuilder", ] diff --git a/mailersend/builders/dmarc_monitoring.py b/mailersend/builders/dmarc_monitoring.py new file mode 100644 index 0000000..44cf8ec --- /dev/null +++ b/mailersend/builders/dmarc_monitoring.py @@ -0,0 +1,132 @@ +"""Builder for DMARC Monitoring requests.""" + +from typing import Optional + +from ..exceptions import ValidationError +from ..models.dmarc_monitoring import ( + DmarcMonitoringListRequest, + DmarcMonitoringListQueryParams, + DmarcMonitoringCreateRequest, + DmarcMonitoringUpdateRequest, + DmarcMonitoringDeleteRequest, + DmarcMonitoringReportRequest, + DmarcMonitoringReportQueryParams, + DmarcMonitoringIpReportRequest, + DmarcMonitoringReportSourcesRequest, + DmarcMonitoringFavoriteRequest, +) + + +class DmarcMonitoringBuilder: + """Builder for creating DMARC Monitoring requests using a fluent interface.""" + + def __init__(self): + """Initialize a new DmarcMonitoringBuilder.""" + self._reset() + + def _reset(self): + """Reset all builder state.""" + self._monitor_id: Optional[str] = None + self._ip: Optional[str] = None + self._domain_id: Optional[str] = None + self._wanted_dmarc_record: Optional[str] = None + self._page: Optional[int] = None + self._limit: Optional[int] = None + + def monitor_id(self, monitor_id: str) -> "DmarcMonitoringBuilder": + """Set the monitor ID.""" + self._monitor_id = monitor_id + return self + + def ip(self, ip: str) -> "DmarcMonitoringBuilder": + """Set the IP address.""" + self._ip = ip + return self + + def domain_id(self, domain_id: str) -> "DmarcMonitoringBuilder": + """Set the domain ID for creating a monitor.""" + self._domain_id = domain_id + return self + + def wanted_dmarc_record(self, record: str) -> "DmarcMonitoringBuilder": + """Set the wanted DMARC record for updating a monitor.""" + self._wanted_dmarc_record = record + return self + + def page(self, page: int) -> "DmarcMonitoringBuilder": + """Set the page number for pagination.""" + if page < 1: + raise ValidationError("Page must be greater than 0") + self._page = page + return self + + def limit(self, limit: int) -> "DmarcMonitoringBuilder": + """Set the number of items per page.""" + if limit < 10 or limit > 100: + raise ValidationError("Limit must be between 10 and 100") + self._limit = limit + return self + + def build_list_request(self) -> DmarcMonitoringListRequest: + """Build a DmarcMonitoringListRequest.""" + query_params = DmarcMonitoringListQueryParams( + page=self._page if self._page is not None else 1, + limit=self._limit if self._limit is not None else 25, + ) + return DmarcMonitoringListRequest(query_params=query_params) + + def build_create_request(self) -> DmarcMonitoringCreateRequest: + """Build a DmarcMonitoringCreateRequest.""" + return DmarcMonitoringCreateRequest(domain_id=self._domain_id) + + def build_update_request(self) -> DmarcMonitoringUpdateRequest: + """Build a DmarcMonitoringUpdateRequest.""" + return DmarcMonitoringUpdateRequest( + monitor_id=self._monitor_id, + wanted_dmarc_record=self._wanted_dmarc_record, + ) + + def build_delete_request(self) -> DmarcMonitoringDeleteRequest: + """Build a DmarcMonitoringDeleteRequest.""" + return DmarcMonitoringDeleteRequest(monitor_id=self._monitor_id) + + def build_report_request(self) -> DmarcMonitoringReportRequest: + """Build a DmarcMonitoringReportRequest for aggregated reports.""" + query_params = DmarcMonitoringReportQueryParams( + page=self._page if self._page is not None else 1, + limit=self._limit if self._limit is not None else 25, + ) + return DmarcMonitoringReportRequest( + monitor_id=self._monitor_id, + query_params=query_params, + ) + + def build_ip_report_request(self) -> DmarcMonitoringIpReportRequest: + """Build a DmarcMonitoringIpReportRequest for IP-specific reports.""" + query_params = DmarcMonitoringReportQueryParams( + page=self._page if self._page is not None else 1, + limit=self._limit if self._limit is not None else 25, + ) + return DmarcMonitoringIpReportRequest( + monitor_id=self._monitor_id, + ip=self._ip, + query_params=query_params, + ) + + def build_report_sources_request(self) -> DmarcMonitoringReportSourcesRequest: + """Build a DmarcMonitoringReportSourcesRequest.""" + return DmarcMonitoringReportSourcesRequest(monitor_id=self._monitor_id) + + def build_mark_favorite_request(self) -> DmarcMonitoringFavoriteRequest: + """Build a DmarcMonitoringFavoriteRequest for marking an IP as favorite.""" + return DmarcMonitoringFavoriteRequest( + monitor_id=self._monitor_id, + ip=self._ip, + ) + + def build_remove_favorite_request(self) -> DmarcMonitoringFavoriteRequest: + """Build a DmarcMonitoringFavoriteRequest for removing an IP from favorites.""" + return DmarcMonitoringFavoriteRequest( + monitor_id=self._monitor_id, + ip=self._ip, + ) diff --git a/mailersend/client.py b/mailersend/client.py index 99f6935..75c5fa4 100644 --- a/mailersend/client.py +++ b/mailersend/client.py @@ -35,6 +35,7 @@ from .resources.sms_sending import SmsSending from .resources.sms_webhooks import SmsWebhooks from .resources.other import Other +from .resources.dmarc_monitoring import DmarcMonitoring from .logging import get_logger, RequestLogger @@ -142,6 +143,7 @@ def __init__( self.sms_recipients = SmsRecipients(self) self.sms_webhooks = SmsWebhooks(self) self.api_quota = Other(self) + self.dmarc_monitoring = DmarcMonitoring(self) self.logger.info("MailerSend client initialized successfully") if debug: diff --git a/mailersend/models/__init__.py b/mailersend/models/__init__.py index 0a1da39..22237c5 100644 --- a/mailersend/models/__init__.py +++ b/mailersend/models/__init__.py @@ -159,6 +159,16 @@ SmsInboundDeleteRequest, SmsInbound, ) +from .dmarc_monitoring import ( + DmarcMonitoringListRequest, + DmarcMonitoringCreateRequest, + DmarcMonitoringUpdateRequest, + DmarcMonitoringDeleteRequest, + DmarcMonitoringReportRequest, + DmarcMonitoringIpReportRequest, + DmarcMonitoringReportSourcesRequest, + DmarcMonitoringFavoriteRequest, +) __all__ = [ "BaseModel", @@ -289,4 +299,13 @@ "SmsInboundUpdateRequest", "SmsInboundDeleteRequest", "SmsInbound", + # DMARC Monitoring models + "DmarcMonitoringListRequest", + "DmarcMonitoringCreateRequest", + "DmarcMonitoringUpdateRequest", + "DmarcMonitoringDeleteRequest", + "DmarcMonitoringReportRequest", + "DmarcMonitoringIpReportRequest", + "DmarcMonitoringReportSourcesRequest", + "DmarcMonitoringFavoriteRequest", ] diff --git a/mailersend/models/dmarc_monitoring.py b/mailersend/models/dmarc_monitoring.py new file mode 100644 index 0000000..7ed0431 --- /dev/null +++ b/mailersend/models/dmarc_monitoring.py @@ -0,0 +1,180 @@ +"""DMARC Monitoring models.""" + +from typing import Optional, Dict, Any + +from pydantic import Field, field_validator + +from .base import BaseModel + + +class DmarcMonitoringListQueryParams(BaseModel): + """Query parameters for listing DMARC monitors.""" + + page: int = Field(default=1, ge=1) + limit: int = Field(default=25, ge=10, le=100) + + def to_query_params(self) -> Dict[str, Any]: + """Convert to query parameters dictionary, excluding None values.""" + return {"page": self.page, "limit": self.limit} + + +class DmarcMonitoringListRequest(BaseModel): + """Request model for listing DMARC monitors.""" + + query_params: DmarcMonitoringListQueryParams = Field( + default_factory=DmarcMonitoringListQueryParams + ) + + def to_query_params(self) -> Dict[str, Any]: + """Convert to query parameters dictionary.""" + return self.query_params.to_query_params() + + +class DmarcMonitoringCreateRequest(BaseModel): + """Request model for creating a DMARC monitor.""" + + domain_id: str + + @field_validator("domain_id") + @classmethod + def validate_domain_id(cls, v: str) -> str: + """Validate domain_id is provided and not empty.""" + if not v or not v.strip(): + raise ValueError("domain_id cannot be empty") + return v.strip() + + +class DmarcMonitoringUpdateRequest(BaseModel): + """Request model for updating a DMARC monitor.""" + + monitor_id: str + wanted_dmarc_record: str + + @field_validator("monitor_id") + @classmethod + def validate_monitor_id(cls, v: str) -> str: + """Validate monitor_id is provided and not empty.""" + if not v or not v.strip(): + raise ValueError("monitor_id cannot be empty") + return v.strip() + + @field_validator("wanted_dmarc_record") + @classmethod + def validate_wanted_dmarc_record(cls, v: str) -> str: + """Validate wanted_dmarc_record is provided and not empty.""" + if not v or not v.strip(): + raise ValueError("wanted_dmarc_record cannot be empty") + return v.strip() + + +class DmarcMonitoringDeleteRequest(BaseModel): + """Request model for deleting a DMARC monitor.""" + + monitor_id: str + + @field_validator("monitor_id") + @classmethod + def validate_monitor_id(cls, v: str) -> str: + """Validate monitor_id is provided and not empty.""" + if not v or not v.strip(): + raise ValueError("monitor_id cannot be empty") + return v.strip() + + +class DmarcMonitoringReportQueryParams(BaseModel): + """Query parameters for DMARC monitoring reports.""" + + page: int = Field(default=1, ge=1) + limit: int = Field(default=25, ge=10, le=100) + + def to_query_params(self) -> Dict[str, Any]: + """Convert to query parameters dictionary.""" + return {"page": self.page, "limit": self.limit} + + +class DmarcMonitoringReportRequest(BaseModel): + """Request model for getting aggregated DMARC reports.""" + + monitor_id: str + query_params: DmarcMonitoringReportQueryParams = Field( + default_factory=DmarcMonitoringReportQueryParams + ) + + @field_validator("monitor_id") + @classmethod + def validate_monitor_id(cls, v: str) -> str: + """Validate monitor_id is provided and not empty.""" + if not v or not v.strip(): + raise ValueError("monitor_id cannot be empty") + return v.strip() + + def to_query_params(self) -> Dict[str, Any]: + """Convert to query parameters dictionary.""" + return self.query_params.to_query_params() + + +class DmarcMonitoringIpReportRequest(BaseModel): + """Request model for getting IP-specific DMARC reports.""" + + monitor_id: str + ip: str + query_params: DmarcMonitoringReportQueryParams = Field( + default_factory=DmarcMonitoringReportQueryParams + ) + + @field_validator("monitor_id") + @classmethod + def validate_monitor_id(cls, v: str) -> str: + """Validate monitor_id is provided and not empty.""" + if not v or not v.strip(): + raise ValueError("monitor_id cannot be empty") + return v.strip() + + @field_validator("ip") + @classmethod + def validate_ip(cls, v: str) -> str: + """Validate ip is provided and not empty.""" + if not v or not v.strip(): + raise ValueError("ip cannot be empty") + return v.strip() + + def to_query_params(self) -> Dict[str, Any]: + """Convert to query parameters dictionary.""" + return self.query_params.to_query_params() + + +class DmarcMonitoringReportSourcesRequest(BaseModel): + """Request model for getting DMARC report sources.""" + + monitor_id: str + + @field_validator("monitor_id") + @classmethod + def validate_monitor_id(cls, v: str) -> str: + """Validate monitor_id is provided and not empty.""" + if not v or not v.strip(): + raise ValueError("monitor_id cannot be empty") + return v.strip() + + +class DmarcMonitoringFavoriteRequest(BaseModel): + """Request model for marking or removing an IP as favorite.""" + + monitor_id: str + ip: str + + @field_validator("monitor_id") + @classmethod + def validate_monitor_id(cls, v: str) -> str: + """Validate monitor_id is provided and not empty.""" + if not v or not v.strip(): + raise ValueError("monitor_id cannot be empty") + return v.strip() + + @field_validator("ip") + @classmethod + def validate_ip(cls, v: str) -> str: + """Validate ip is provided and not empty.""" + if not v or not v.strip(): + raise ValueError("ip cannot be empty") + return v.strip() diff --git a/mailersend/resources/__init__.py b/mailersend/resources/__init__.py index 686428c..44e93ee 100644 --- a/mailersend/resources/__init__.py +++ b/mailersend/resources/__init__.py @@ -25,6 +25,7 @@ from .sms_webhooks import SmsWebhooks from .sms_inbounds import SmsInbounds from .other import Other +from .dmarc_monitoring import DmarcMonitoring __all__ = [ "BaseResource", @@ -50,4 +51,5 @@ "SmsWebhooks", "SmsInbounds", "Other", + "DmarcMonitoring", ] diff --git a/mailersend/resources/dmarc_monitoring.py b/mailersend/resources/dmarc_monitoring.py new file mode 100644 index 0000000..8d53313 --- /dev/null +++ b/mailersend/resources/dmarc_monitoring.py @@ -0,0 +1,216 @@ +"""DMARC Monitoring resource.""" + +from typing import Optional + +from .base import BaseResource +from ..models.base import APIResponse +from ..models.dmarc_monitoring import ( + DmarcMonitoringListRequest, + DmarcMonitoringListQueryParams, + DmarcMonitoringCreateRequest, + DmarcMonitoringUpdateRequest, + DmarcMonitoringDeleteRequest, + DmarcMonitoringReportRequest, + DmarcMonitoringReportQueryParams, + DmarcMonitoringIpReportRequest, + DmarcMonitoringReportSourcesRequest, + DmarcMonitoringFavoriteRequest, +) + + +class DmarcMonitoring(BaseResource): + """Client for interacting with the MailerSend DMARC Monitoring API.""" + + def list_monitors( + self, request: Optional[DmarcMonitoringListRequest] = None + ) -> APIResponse: + """ + Retrieve a list of DMARC monitors. + + Args: + request: Optional DmarcMonitoringListRequest with pagination options + + Returns: + APIResponse with list of monitors + """ + if request is None: + query_params = DmarcMonitoringListQueryParams() + request = DmarcMonitoringListRequest(query_params=query_params) + + params = request.to_query_params() + self.logger.debug("Listing DMARC monitors with params: %s", params) + + response = self.client.request( + method="GET", path="dmarc-monitoring", params=params + ) + return self._create_response(response) + + def create_monitor(self, request: DmarcMonitoringCreateRequest) -> APIResponse: + """ + Create a new DMARC monitor. + + Args: + request: DmarcMonitoringCreateRequest with domain_id + + Returns: + APIResponse with created monitor information + """ + body = request.model_dump(by_alias=True, exclude_none=True) + self.logger.debug("Creating DMARC monitor with body: %s", body) + + response = self.client.request( + method="POST", path="dmarc-monitoring", body=body + ) + return self._create_response(response) + + def update_monitor(self, request: DmarcMonitoringUpdateRequest) -> APIResponse: + """ + Update a DMARC monitor. + + Args: + request: DmarcMonitoringUpdateRequest with monitor_id and wanted_dmarc_record + + Returns: + APIResponse with updated monitor information + """ + body = request.model_dump( + by_alias=True, exclude_none=True, exclude={"monitor_id"} + ) + self.logger.debug("Updating DMARC monitor %s with body: %s", request.monitor_id, body) + + response = self.client.request( + method="PUT", path=f"dmarc-monitoring/{request.monitor_id}", body=body + ) + return self._create_response(response) + + def delete_monitor(self, request: DmarcMonitoringDeleteRequest) -> APIResponse: + """ + Delete a DMARC monitor. + + Args: + request: DmarcMonitoringDeleteRequest with monitor_id + + Returns: + APIResponse + """ + self.logger.debug("Deleting DMARC monitor: %s", request.monitor_id) + + response = self.client.request( + method="DELETE", path=f"dmarc-monitoring/{request.monitor_id}" + ) + return self._create_response(response) + + def get_aggregated_report( + self, request: DmarcMonitoringReportRequest + ) -> APIResponse: + """ + Get aggregated DMARC reports for a monitor. + + Args: + request: DmarcMonitoringReportRequest with monitor_id and pagination options + + Returns: + APIResponse with aggregated report data + """ + params = request.to_query_params() + self.logger.debug( + "Getting aggregated report for monitor %s with params: %s", + request.monitor_id, + params, + ) + + response = self.client.request( + method="GET", + path=f"dmarc-monitoring/{request.monitor_id}/report", + params=params, + ) + return self._create_response(response) + + def get_ip_report(self, request: DmarcMonitoringIpReportRequest) -> APIResponse: + """ + Get IP-specific DMARC reports for a monitor. + + Args: + request: DmarcMonitoringIpReportRequest with monitor_id, ip, and pagination options + + Returns: + APIResponse with IP-specific report data + """ + params = request.to_query_params() + self.logger.debug( + "Getting IP report for monitor %s, IP %s with params: %s", + request.monitor_id, + request.ip, + params, + ) + + response = self.client.request( + method="GET", + path=f"dmarc-monitoring/{request.monitor_id}/report/{request.ip}", + params=params, + ) + return self._create_response(response) + + def get_report_sources( + self, request: DmarcMonitoringReportSourcesRequest + ) -> APIResponse: + """ + Get report sources for a DMARC monitor. + + Args: + request: DmarcMonitoringReportSourcesRequest with monitor_id + + Returns: + APIResponse with report sources data + """ + self.logger.debug("Getting report sources for monitor: %s", request.monitor_id) + + response = self.client.request( + method="GET", + path=f"dmarc-monitoring/{request.monitor_id}/report-sources", + ) + return self._create_response(response) + + def mark_ip_favorite(self, request: DmarcMonitoringFavoriteRequest) -> APIResponse: + """ + Mark an IP address as favorite for a DMARC monitor. + + Args: + request: DmarcMonitoringFavoriteRequest with monitor_id and ip + + Returns: + APIResponse + """ + self.logger.debug( + "Marking IP %s as favorite for monitor: %s", request.ip, request.monitor_id + ) + + response = self.client.request( + method="PUT", + path=f"dmarc-monitoring/{request.monitor_id}/favorite/{request.ip}", + ) + return self._create_response(response) + + def remove_ip_favorite( + self, request: DmarcMonitoringFavoriteRequest + ) -> APIResponse: + """ + Remove an IP address from favorites for a DMARC monitor. + + Args: + request: DmarcMonitoringFavoriteRequest with monitor_id and ip + + Returns: + APIResponse + """ + self.logger.debug( + "Removing IP %s from favorites for monitor: %s", + request.ip, + request.monitor_id, + ) + + response = self.client.request( + method="DELETE", + path=f"dmarc-monitoring/{request.monitor_id}/favorite/{request.ip}", + ) + return self._create_response(response) diff --git a/tests/unit/test_dmarc_monitoring_resource.py b/tests/unit/test_dmarc_monitoring_resource.py new file mode 100644 index 0000000..3faf19e --- /dev/null +++ b/tests/unit/test_dmarc_monitoring_resource.py @@ -0,0 +1,502 @@ +"""Unit tests for DMARC Monitoring resource.""" + +import pytest +from unittest.mock import Mock, MagicMock + +from mailersend.resources.dmarc_monitoring import DmarcMonitoring +from mailersend.models.base import APIResponse +from mailersend.models.dmarc_monitoring import ( + DmarcMonitoringListRequest, + DmarcMonitoringListQueryParams, + DmarcMonitoringCreateRequest, + DmarcMonitoringUpdateRequest, + DmarcMonitoringDeleteRequest, + DmarcMonitoringReportRequest, + DmarcMonitoringReportQueryParams, + DmarcMonitoringIpReportRequest, + DmarcMonitoringReportSourcesRequest, + DmarcMonitoringFavoriteRequest, +) + + +class TestDmarcMonitoringInit: + """Test DmarcMonitoring resource initialization.""" + + def test_initialization(self): + """Test DmarcMonitoring resource initializes correctly.""" + mock_client = Mock() + resource = DmarcMonitoring(mock_client) + + assert resource.client is mock_client + assert resource.logger is not None + + +class TestListMonitors: + """Test list_monitors method.""" + + def setup_method(self): + """Set up test fixtures.""" + self.mock_client = Mock() + self.resource = DmarcMonitoring(self.mock_client) + self.resource.logger = Mock() + self.mock_api_response = MagicMock(spec=APIResponse) + self.resource._create_response = Mock(return_value=self.mock_api_response) + + def test_list_monitors_returns_api_response(self): + """Test list_monitors returns APIResponse.""" + mock_response = Mock() + self.mock_client.request.return_value = mock_response + + result = self.resource.list_monitors() + + assert result == self.mock_api_response + self.resource._create_response.assert_called_once_with(mock_response) + + def test_list_monitors_default_params(self): + """Test list_monitors with no request uses defaults.""" + mock_response = Mock() + self.mock_client.request.return_value = mock_response + + self.resource.list_monitors() + + self.mock_client.request.assert_called_once_with( + method="GET", path="dmarc-monitoring", params={"page": 1, "limit": 25} + ) + + def test_list_monitors_with_custom_params(self): + """Test list_monitors with custom pagination.""" + mock_response = Mock() + self.mock_client.request.return_value = mock_response + + query_params = DmarcMonitoringListQueryParams(page=2, limit=50) + request = DmarcMonitoringListRequest(query_params=query_params) + + self.resource.list_monitors(request) + + self.mock_client.request.assert_called_once_with( + method="GET", path="dmarc-monitoring", params={"page": 2, "limit": 50} + ) + + def test_list_monitors_with_explicit_request(self): + """Test list_monitors with explicit request object.""" + mock_response = Mock() + self.mock_client.request.return_value = mock_response + + query_params = DmarcMonitoringListQueryParams(page=1, limit=10) + request = DmarcMonitoringListRequest(query_params=query_params) + + self.resource.list_monitors(request) + + self.mock_client.request.assert_called_once_with( + method="GET", path="dmarc-monitoring", params={"page": 1, "limit": 10} + ) + + +class TestCreateMonitor: + """Test create_monitor method.""" + + def setup_method(self): + """Set up test fixtures.""" + self.mock_client = Mock() + self.resource = DmarcMonitoring(self.mock_client) + self.resource.logger = Mock() + self.mock_api_response = MagicMock(spec=APIResponse) + self.resource._create_response = Mock(return_value=self.mock_api_response) + + def test_create_monitor_returns_api_response(self): + """Test create_monitor returns APIResponse.""" + mock_response = Mock() + self.mock_client.request.return_value = mock_response + + request = DmarcMonitoringCreateRequest(domain_id="domain-123") + result = self.resource.create_monitor(request) + + assert result == self.mock_api_response + self.resource._create_response.assert_called_once_with(mock_response) + + def test_create_monitor_sends_correct_body(self): + """Test create_monitor sends domain_id in request body.""" + mock_response = Mock() + self.mock_client.request.return_value = mock_response + + request = DmarcMonitoringCreateRequest(domain_id="domain-123") + self.resource.create_monitor(request) + + self.mock_client.request.assert_called_once_with( + method="POST", + path="dmarc-monitoring", + body={"domain_id": "domain-123"}, + ) + + def test_create_monitor_uses_post_method(self): + """Test create_monitor uses POST HTTP method.""" + mock_response = Mock() + self.mock_client.request.return_value = mock_response + + request = DmarcMonitoringCreateRequest(domain_id="domain-abc") + self.resource.create_monitor(request) + + call_args = self.mock_client.request.call_args + assert call_args[1]["method"] == "POST" + + +class TestUpdateMonitor: + """Test update_monitor method.""" + + def setup_method(self): + """Set up test fixtures.""" + self.mock_client = Mock() + self.resource = DmarcMonitoring(self.mock_client) + self.resource.logger = Mock() + self.mock_api_response = MagicMock(spec=APIResponse) + self.resource._create_response = Mock(return_value=self.mock_api_response) + + def test_update_monitor_returns_api_response(self): + """Test update_monitor returns APIResponse.""" + mock_response = Mock() + self.mock_client.request.return_value = mock_response + + request = DmarcMonitoringUpdateRequest( + monitor_id="monitor-123", + wanted_dmarc_record="v=DMARC1; p=reject;", + ) + result = self.resource.update_monitor(request) + + assert result == self.mock_api_response + self.resource._create_response.assert_called_once_with(mock_response) + + def test_update_monitor_sends_correct_body_and_path(self): + """Test update_monitor sends wanted_dmarc_record in body and monitor_id in path.""" + mock_response = Mock() + self.mock_client.request.return_value = mock_response + + request = DmarcMonitoringUpdateRequest( + monitor_id="monitor-123", + wanted_dmarc_record="v=DMARC1; p=reject;", + ) + self.resource.update_monitor(request) + + self.mock_client.request.assert_called_once_with( + method="PUT", + path="dmarc-monitoring/monitor-123", + body={"wanted_dmarc_record": "v=DMARC1; p=reject;"}, + ) + + def test_update_monitor_excludes_monitor_id_from_body(self): + """Test update_monitor does not include monitor_id in request body.""" + mock_response = Mock() + self.mock_client.request.return_value = mock_response + + request = DmarcMonitoringUpdateRequest( + monitor_id="monitor-123", + wanted_dmarc_record="v=DMARC1; p=none;", + ) + self.resource.update_monitor(request) + + call_args = self.mock_client.request.call_args + body = call_args[1]["body"] + assert "monitor_id" not in body + assert "wanted_dmarc_record" in body + + +class TestDeleteMonitor: + """Test delete_monitor method.""" + + def setup_method(self): + """Set up test fixtures.""" + self.mock_client = Mock() + self.resource = DmarcMonitoring(self.mock_client) + self.resource.logger = Mock() + self.mock_api_response = MagicMock(spec=APIResponse) + self.resource._create_response = Mock(return_value=self.mock_api_response) + + def test_delete_monitor_returns_api_response(self): + """Test delete_monitor returns APIResponse.""" + mock_response = Mock() + self.mock_client.request.return_value = mock_response + + request = DmarcMonitoringDeleteRequest(monitor_id="monitor-123") + result = self.resource.delete_monitor(request) + + assert result == self.mock_api_response + self.resource._create_response.assert_called_once_with(mock_response) + + def test_delete_monitor_uses_correct_path(self): + """Test delete_monitor constructs correct endpoint path.""" + mock_response = Mock() + self.mock_client.request.return_value = mock_response + + request = DmarcMonitoringDeleteRequest(monitor_id="monitor-abc") + self.resource.delete_monitor(request) + + self.mock_client.request.assert_called_once_with( + method="DELETE", path="dmarc-monitoring/monitor-abc" + ) + + +class TestGetAggregatedReport: + """Test get_aggregated_report method.""" + + def setup_method(self): + """Set up test fixtures.""" + self.mock_client = Mock() + self.resource = DmarcMonitoring(self.mock_client) + self.resource.logger = Mock() + self.mock_api_response = MagicMock(spec=APIResponse) + self.resource._create_response = Mock(return_value=self.mock_api_response) + + def test_get_aggregated_report_returns_api_response(self): + """Test get_aggregated_report returns APIResponse.""" + mock_response = Mock() + self.mock_client.request.return_value = mock_response + + request = DmarcMonitoringReportRequest(monitor_id="monitor-123") + result = self.resource.get_aggregated_report(request) + + assert result == self.mock_api_response + self.resource._create_response.assert_called_once_with(mock_response) + + def test_get_aggregated_report_default_params(self): + """Test get_aggregated_report with default pagination.""" + mock_response = Mock() + self.mock_client.request.return_value = mock_response + + request = DmarcMonitoringReportRequest(monitor_id="monitor-123") + self.resource.get_aggregated_report(request) + + self.mock_client.request.assert_called_once_with( + method="GET", + path="dmarc-monitoring/monitor-123/report", + params={"page": 1, "limit": 25}, + ) + + def test_get_aggregated_report_custom_params(self): + """Test get_aggregated_report with custom pagination.""" + mock_response = Mock() + self.mock_client.request.return_value = mock_response + + query_params = DmarcMonitoringReportQueryParams(page=3, limit=100) + request = DmarcMonitoringReportRequest( + monitor_id="monitor-123", query_params=query_params + ) + self.resource.get_aggregated_report(request) + + self.mock_client.request.assert_called_once_with( + method="GET", + path="dmarc-monitoring/monitor-123/report", + params={"page": 3, "limit": 100}, + ) + + +class TestGetIpReport: + """Test get_ip_report method.""" + + def setup_method(self): + """Set up test fixtures.""" + self.mock_client = Mock() + self.resource = DmarcMonitoring(self.mock_client) + self.resource.logger = Mock() + self.mock_api_response = MagicMock(spec=APIResponse) + self.resource._create_response = Mock(return_value=self.mock_api_response) + + def test_get_ip_report_returns_api_response(self): + """Test get_ip_report returns APIResponse.""" + mock_response = Mock() + self.mock_client.request.return_value = mock_response + + request = DmarcMonitoringIpReportRequest( + monitor_id="monitor-123", ip="192.168.1.1" + ) + result = self.resource.get_ip_report(request) + + assert result == self.mock_api_response + self.resource._create_response.assert_called_once_with(mock_response) + + def test_get_ip_report_constructs_correct_path(self): + """Test get_ip_report constructs correct endpoint path.""" + mock_response = Mock() + self.mock_client.request.return_value = mock_response + + request = DmarcMonitoringIpReportRequest( + monitor_id="monitor-123", ip="10.0.0.1" + ) + self.resource.get_ip_report(request) + + self.mock_client.request.assert_called_once_with( + method="GET", + path="dmarc-monitoring/monitor-123/report/10.0.0.1", + params={"page": 1, "limit": 25}, + ) + + def test_get_ip_report_with_custom_params(self): + """Test get_ip_report with custom pagination.""" + mock_response = Mock() + self.mock_client.request.return_value = mock_response + + query_params = DmarcMonitoringReportQueryParams(page=2, limit=50) + request = DmarcMonitoringIpReportRequest( + monitor_id="monitor-123", ip="10.0.0.1", query_params=query_params + ) + self.resource.get_ip_report(request) + + self.mock_client.request.assert_called_once_with( + method="GET", + path="dmarc-monitoring/monitor-123/report/10.0.0.1", + params={"page": 2, "limit": 50}, + ) + + +class TestGetReportSources: + """Test get_report_sources method.""" + + def setup_method(self): + """Set up test fixtures.""" + self.mock_client = Mock() + self.resource = DmarcMonitoring(self.mock_client) + self.resource.logger = Mock() + self.mock_api_response = MagicMock(spec=APIResponse) + self.resource._create_response = Mock(return_value=self.mock_api_response) + + def test_get_report_sources_returns_api_response(self): + """Test get_report_sources returns APIResponse.""" + mock_response = Mock() + self.mock_client.request.return_value = mock_response + + request = DmarcMonitoringReportSourcesRequest(monitor_id="monitor-123") + result = self.resource.get_report_sources(request) + + assert result == self.mock_api_response + self.resource._create_response.assert_called_once_with(mock_response) + + def test_get_report_sources_constructs_correct_path(self): + """Test get_report_sources constructs correct endpoint path.""" + mock_response = Mock() + self.mock_client.request.return_value = mock_response + + request = DmarcMonitoringReportSourcesRequest(monitor_id="monitor-abc") + self.resource.get_report_sources(request) + + self.mock_client.request.assert_called_once_with( + method="GET", + path="dmarc-monitoring/monitor-abc/report-sources", + ) + + +class TestMarkIpFavorite: + """Test mark_ip_favorite method.""" + + def setup_method(self): + """Set up test fixtures.""" + self.mock_client = Mock() + self.resource = DmarcMonitoring(self.mock_client) + self.resource.logger = Mock() + self.mock_api_response = MagicMock(spec=APIResponse) + self.resource._create_response = Mock(return_value=self.mock_api_response) + + def test_mark_ip_favorite_returns_api_response(self): + """Test mark_ip_favorite returns APIResponse.""" + mock_response = Mock() + self.mock_client.request.return_value = mock_response + + request = DmarcMonitoringFavoriteRequest( + monitor_id="monitor-123", ip="192.168.1.1" + ) + result = self.resource.mark_ip_favorite(request) + + assert result == self.mock_api_response + self.resource._create_response.assert_called_once_with(mock_response) + + def test_mark_ip_favorite_constructs_correct_path(self): + """Test mark_ip_favorite constructs correct endpoint path.""" + mock_response = Mock() + self.mock_client.request.return_value = mock_response + + request = DmarcMonitoringFavoriteRequest( + monitor_id="monitor-123", ip="10.0.0.1" + ) + self.resource.mark_ip_favorite(request) + + self.mock_client.request.assert_called_once_with( + method="PUT", + path="dmarc-monitoring/monitor-123/favorite/10.0.0.1", + ) + + +class TestRemoveIpFavorite: + """Test remove_ip_favorite method.""" + + def setup_method(self): + """Set up test fixtures.""" + self.mock_client = Mock() + self.resource = DmarcMonitoring(self.mock_client) + self.resource.logger = Mock() + self.mock_api_response = MagicMock(spec=APIResponse) + self.resource._create_response = Mock(return_value=self.mock_api_response) + + def test_remove_ip_favorite_returns_api_response(self): + """Test remove_ip_favorite returns APIResponse.""" + mock_response = Mock() + self.mock_client.request.return_value = mock_response + + request = DmarcMonitoringFavoriteRequest( + monitor_id="monitor-123", ip="192.168.1.1" + ) + result = self.resource.remove_ip_favorite(request) + + assert result == self.mock_api_response + self.resource._create_response.assert_called_once_with(mock_response) + + def test_remove_ip_favorite_constructs_correct_path(self): + """Test remove_ip_favorite constructs correct endpoint path.""" + mock_response = Mock() + self.mock_client.request.return_value = mock_response + + request = DmarcMonitoringFavoriteRequest( + monitor_id="monitor-123", ip="10.0.0.1" + ) + self.resource.remove_ip_favorite(request) + + self.mock_client.request.assert_called_once_with( + method="DELETE", + path="dmarc-monitoring/monitor-123/favorite/10.0.0.1", + ) + + +class TestModelValidation: + """Test model validation.""" + + def test_create_request_requires_domain_id(self): + """Test DmarcMonitoringCreateRequest raises error for empty domain_id.""" + with pytest.raises(Exception): + DmarcMonitoringCreateRequest(domain_id="") + + def test_update_request_requires_monitor_id(self): + """Test DmarcMonitoringUpdateRequest raises error for empty monitor_id.""" + with pytest.raises(Exception): + DmarcMonitoringUpdateRequest( + monitor_id="", wanted_dmarc_record="v=DMARC1; p=reject;" + ) + + def test_update_request_requires_wanted_dmarc_record(self): + """Test DmarcMonitoringUpdateRequest raises error for empty wanted_dmarc_record.""" + with pytest.raises(Exception): + DmarcMonitoringUpdateRequest(monitor_id="monitor-123", wanted_dmarc_record="") + + def test_list_query_params_default_values(self): + """Test DmarcMonitoringListQueryParams has correct defaults.""" + params = DmarcMonitoringListQueryParams() + assert params.page == 1 + assert params.limit == 25 + + def test_list_query_params_limit_validation(self): + """Test DmarcMonitoringListQueryParams validates limit range.""" + with pytest.raises(Exception): + DmarcMonitoringListQueryParams(limit=5) # below min of 10 + + with pytest.raises(Exception): + DmarcMonitoringListQueryParams(limit=101) # above max of 100 + + def test_favorite_request_requires_ip(self): + """Test DmarcMonitoringFavoriteRequest raises error for empty ip.""" + with pytest.raises(Exception): + DmarcMonitoringFavoriteRequest(monitor_id="monitor-123", ip="")