diff --git a/CHANGELOG.md b/CHANGELOG.md index efece91..4b7e6a0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ Unreleased * Added Manage Domains (`Client.domains`, `/v3/admin/domains`): list, create, find, update, delete, `get_info`, and `verify` with models in `nylas.models.domains`; optional `ServiceAccountSigner` (`nylas.handler.service_account`) for service-account headers (`X-Nylas-Kid`, `X-Nylas-Nonce`, `X-Nylas-Timestamp`, `X-Nylas-Signature`) on each `Domains` method; new `cryptography` dependency, RSA signing, and `HttpClient` `serialized_json_body` so signed payloads match the wire body * Added Transactional Send: `Client.transactional_send.send()` for `POST /v3/domains/{domain_name}/messages/send`, with `TransactionalSendMessageRequest` and `TransactionalTemplate` models (JSON and multipart send behavior aligned with grant `messages.send`) * Added Policies support (`Client.policies`, `/v3/policies`): list, create, find, update, and delete, with typed request/response models in `nylas.models.policies` +* Added Rules support (`Client.rules`): list, create, find, update, and delete for `/v3/rules`, plus `list_evaluations` for `/v3/grants/{grant_id}/rule-evaluations`, with typed request/response models in `nylas.models.rules` v6.14.3 ---------- diff --git a/nylas/client.py b/nylas/client.py index a5e3f41..8bad36e 100644 --- a/nylas/client.py +++ b/nylas/client.py @@ -18,6 +18,7 @@ from nylas.resources.policies import Policies from nylas.resources.scheduler import Scheduler from nylas.resources.notetakers import Notetakers +from nylas.resources.rules import Rules class Client: @@ -165,6 +166,16 @@ def policies(self) -> Policies: """ return Policies(self.http_client) + @property + def rules(self) -> Rules: + """ + Access the Rules API. + + Returns: + The Rules API. + """ + return Rules(self.http_client) + @property def messages(self) -> Messages: """ diff --git a/nylas/models/rules.py b/nylas/models/rules.py new file mode 100644 index 0000000..2299c1f --- /dev/null +++ b/nylas/models/rules.py @@ -0,0 +1,155 @@ +from dataclasses import dataclass +from typing import Any, List, Optional + +from dataclasses_json import dataclass_json +from typing_extensions import NotRequired, TypedDict + +from nylas.models.list_query_params import ListQueryParams + + +class ListRulesQueryParams(ListQueryParams): + """Query parameters for listing rules.""" + + pass + + +class ListRuleEvaluationsQueryParams(ListQueryParams): + """Query parameters for listing rule evaluations.""" + + pass + + +class RuleConditionRequest(TypedDict): + """A single condition used in a rule match clause.""" + + field: str + operator: str + value: Any + + +class RuleMatchRequest(TypedDict): + """Match clause for create/update rule requests.""" + + conditions: List[RuleConditionRequest] + operator: NotRequired[str] + + +class RuleActionRequest(TypedDict): + """Action object used in create/update rule requests.""" + + type: str + value: NotRequired[str] + + +class CreateRuleRequest(TypedDict): + """Request body for creating a rule.""" + + name: str + match: RuleMatchRequest + actions: List[RuleActionRequest] + description: NotRequired[str] + priority: NotRequired[int] + enabled: NotRequired[bool] + trigger: NotRequired[str] + + +class UpdateRuleRequest(TypedDict, total=False): + """Request body for updating a rule.""" + + name: NotRequired[str] + match: NotRequired[RuleMatchRequest] + actions: NotRequired[List[RuleActionRequest]] + description: NotRequired[str] + priority: NotRequired[int] + enabled: NotRequired[bool] + trigger: NotRequired[str] + + +@dataclass_json +@dataclass +class RuleCondition: + """A condition in a rule match clause.""" + + field: Optional[str] = None + operator: Optional[str] = None + value: Optional[Any] = None + + +@dataclass_json +@dataclass +class RuleMatch: + """A rule's condition set and matching strategy.""" + + operator: Optional[str] = None + conditions: Optional[List[RuleCondition]] = None + + +@dataclass_json +@dataclass +class RuleAction: + """An action applied when a rule matches.""" + + type: Optional[str] = None + value: Optional[str] = None + + +@dataclass_json +@dataclass +class Rule: + """A rule used for automated filtering and routing.""" + + id: Optional[str] = None + name: Optional[str] = None + description: Optional[str] = None + priority: Optional[int] = None + enabled: Optional[bool] = None + trigger: Optional[str] = None + match: Optional[RuleMatch] = None + actions: Optional[List[RuleAction]] = None + application_id: Optional[str] = None + organization_id: Optional[str] = None + created_at: Optional[int] = None + updated_at: Optional[int] = None + + +@dataclass_json +@dataclass +class RuleEvaluationInput: + """Sender data used as input to rule evaluation.""" + + from_address: Optional[str] = None + from_domain: Optional[str] = None + from_tld: Optional[str] = None + + +@dataclass_json +@dataclass +class RuleEvaluationAppliedActions: + """Actions applied when rules matched.""" + + blocked: Optional[bool] = None + marked_as_spam: Optional[bool] = None + marked_as_read: Optional[bool] = None + marked_starred: Optional[bool] = None + archived: Optional[bool] = None + trashed: Optional[bool] = None + folder_ids: Optional[List[str]] = None + + +@dataclass_json +@dataclass +class RuleEvaluation: + """An audit record describing rule evaluation for a grant.""" + + id: Optional[str] = None + grant_id: Optional[str] = None + message_id: Optional[str] = None + evaluated_at: Optional[int] = None + evaluation_stage: Optional[str] = None + evaluation_input: Optional[RuleEvaluationInput] = None + applied_actions: Optional[RuleEvaluationAppliedActions] = None + matched_rule_ids: Optional[List[str]] = None + application_id: Optional[str] = None + organization_id: Optional[str] = None + created_at: Optional[int] = None + updated_at: Optional[int] = None diff --git a/nylas/resources/rules.py b/nylas/resources/rules.py new file mode 100644 index 0000000..698831d --- /dev/null +++ b/nylas/resources/rules.py @@ -0,0 +1,94 @@ +from nylas.config import RequestOverrides +from nylas.handler.api_resources import ( + CreatableApiResource, + DestroyableApiResource, + FindableApiResource, + ListableApiResource, + UpdatableApiResource, +) +from nylas.models.response import DeleteResponse, ListResponse, Response +from nylas.models.rules import ( + CreateRuleRequest, + ListRuleEvaluationsQueryParams, + ListRulesQueryParams, + Rule, + RuleEvaluation, + UpdateRuleRequest, +) + + +class Rules( + ListableApiResource, + FindableApiResource, + CreatableApiResource, + UpdatableApiResource, + DestroyableApiResource, +): + """Nylas Rules API.""" + + def list( + self, + query_params: ListRulesQueryParams = None, + overrides: RequestOverrides = None, + ) -> ListResponse[Rule]: + """Return all rules for the application.""" + return super().list( + path="/v3/rules", + response_type=Rule, + query_params=query_params, + overrides=overrides, + ) + + def create( + self, + request_body: CreateRuleRequest, + overrides: RequestOverrides = None, + ) -> Response[Rule]: + """Create a new rule.""" + return super().create( + path="/v3/rules", + request_body=request_body, + response_type=Rule, + overrides=overrides, + ) + + def find(self, rule_id: str, overrides: RequestOverrides = None) -> Response[Rule]: + """Return a specific rule by ID.""" + return super().find( + path=f"/v3/rules/{rule_id}", + response_type=Rule, + overrides=overrides, + ) + + def update( + self, + rule_id: str, + request_body: UpdateRuleRequest, + overrides: RequestOverrides = None, + ) -> Response[Rule]: + """Update a rule by ID.""" + return super().update( + path=f"/v3/rules/{rule_id}", + response_type=Rule, + request_body=request_body, + method="PUT", + overrides=overrides, + ) + + def destroy(self, rule_id: str, overrides: RequestOverrides = None) -> DeleteResponse: + """Delete a rule by ID.""" + return super().destroy(path=f"/v3/rules/{rule_id}", overrides=overrides) + + def list_evaluations( + self, + grant_id: str, + query_params: ListRuleEvaluationsQueryParams = None, + overrides: RequestOverrides = None, + ) -> ListResponse[RuleEvaluation]: + """Return rule evaluation audit records for a grant.""" + return super().list( + path=f"/v3/grants/{grant_id}/rule-evaluations", + response_type=RuleEvaluation, + query_params=query_params, + overrides=overrides, + ) diff --git a/tests/resources/test_rules.py b/tests/resources/test_rules.py new file mode 100644 index 0000000..c881cac --- /dev/null +++ b/tests/resources/test_rules.py @@ -0,0 +1,306 @@ +from nylas.models.rules import Rule, RuleEvaluation +from nylas.resources.rules import Rules + + +class TestRules: + def test_rule_deserialization(self, http_client): + rule_json = { + "id": "rule-123", + "name": "Block spam senders", + "description": "Marks mail from spam-domain.com as spam", + "priority": 1, + "enabled": True, + "trigger": "inbound", + "match": { + "operator": "any", + "conditions": [ + {"field": "from.domain", "operator": "is", "value": "spam-domain.com"} + ], + }, + "actions": [{"type": "mark_as_spam"}], + "application_id": "app-123", + "organization_id": "org-123", + "created_at": 1712450952, + "updated_at": 1712450952, + } + + rule = Rule.from_dict(rule_json) + + assert rule.id == "rule-123" + assert rule.name == "Block spam senders" + assert rule.description == "Marks mail from spam-domain.com as spam" + assert rule.priority == 1 + assert rule.enabled is True + assert rule.trigger == "inbound" + assert rule.match is not None + assert rule.match.operator == "any" + assert rule.match.conditions is not None + assert rule.match.conditions[0].field == "from.domain" + assert rule.match.conditions[0].operator == "is" + assert rule.match.conditions[0].value == "spam-domain.com" + assert rule.actions is not None + assert rule.actions[0].type == "mark_as_spam" + assert rule.actions[0].value is None + assert rule.application_id == "app-123" + assert rule.organization_id == "org-123" + assert rule.created_at == 1712450952 + assert rule.updated_at == 1712450952 + + def test_rule_deserialization_with_minimal_fields(self, http_client): + rule_json = { + "id": "rule-123", + "name": "Minimal rule", + } + + rule = Rule.from_dict(rule_json, infer_missing=True) + + assert rule.id == "rule-123" + assert rule.name == "Minimal rule" + assert rule.description is None + assert rule.match is None + assert rule.actions is None + assert rule.created_at is None + assert rule.updated_at is None + + def test_rule_evaluation_deserialization(self, http_client): + evaluation_json = { + "id": "evaluation-123", + "grant_id": "grant-123", + "message_id": "message-123", + "evaluated_at": 1712450952, + "evaluation_stage": "inbox_processing", + "evaluation_input": { + "from_address": "spammer@spam-domain.com", + "from_domain": "spam-domain.com", + "from_tld": "com", + }, + "applied_actions": { + "marked_as_spam": True, + "archived": True, + "folder_ids": ["spam-folder"], + }, + "matched_rule_ids": ["rule-123"], + "application_id": "app-123", + "organization_id": "org-123", + "created_at": 1712450952, + "updated_at": 1712450952, + } + + evaluation = RuleEvaluation.from_dict(evaluation_json) + + assert evaluation.id == "evaluation-123" + assert evaluation.grant_id == "grant-123" + assert evaluation.message_id == "message-123" + assert evaluation.evaluated_at == 1712450952 + assert evaluation.evaluation_stage == "inbox_processing" + assert evaluation.evaluation_input is not None + assert evaluation.evaluation_input.from_address == "spammer@spam-domain.com" + assert evaluation.applied_actions is not None + assert evaluation.applied_actions.marked_as_spam is True + assert evaluation.applied_actions.archived is True + assert evaluation.applied_actions.folder_ids == ["spam-folder"] + assert evaluation.matched_rule_ids == ["rule-123"] + assert evaluation.application_id == "app-123" + assert evaluation.organization_id == "org-123" + + def test_rule_evaluation_deserialization_with_minimal_fields(self, http_client): + evaluation_json = { + "id": "evaluation-123", + "grant_id": "grant-123", + } + + evaluation = RuleEvaluation.from_dict(evaluation_json, infer_missing=True) + + assert evaluation.id == "evaluation-123" + assert evaluation.grant_id == "grant-123" + assert evaluation.message_id is None + assert evaluation.evaluation_input is None + assert evaluation.applied_actions is None + assert evaluation.matched_rule_ids is None + + def test_list_rules(self, http_client_list_response): + rules = Rules(http_client_list_response) + + rules.list() + + http_client_list_response._execute.assert_called_once_with( + "GET", "/v3/rules", None, None, None, overrides=None + ) + + def test_list_rules_with_query_params(self, http_client_list_response): + rules = Rules(http_client_list_response) + + rules.list(query_params={"limit": 10, "page_token": "next-page-token"}) + + http_client_list_response._execute.assert_called_once_with( + "GET", + "/v3/rules", + None, + {"limit": 10, "page_token": "next-page-token"}, + None, + overrides=None, + ) + + def test_create_rule(self, http_client_response): + rules = Rules(http_client_response) + request_body = { + "name": "Block spam domains", + "priority": 1, + "trigger": "inbound", + "match": { + "operator": "any", + "conditions": [ + {"field": "from.domain", "operator": "is", "value": "spam-domain.com"} + ], + }, + "actions": [{"type": "block"}], + } + + rules.create(request_body) + + http_client_response._execute.assert_called_once_with( + "POST", "/v3/rules", None, None, request_body, overrides=None + ) + + def test_create_rule_with_overrides(self, http_client_response): + rules = Rules(http_client_response) + request_body = { + "name": "Block spam domains", + "match": { + "conditions": [ + {"field": "from.domain", "operator": "is", "value": "spam-domain.com"} + ], + }, + "actions": [{"type": "block"}], + } + overrides = {"headers": {"X-Test": "value"}} + + rules.create(request_body, overrides=overrides) + + http_client_response._execute.assert_called_once_with( + "POST", + "/v3/rules", + None, + None, + request_body, + overrides=overrides, + ) + + def test_find_rule(self, http_client_response): + rules = Rules(http_client_response) + + rules.find("rule-123") + + http_client_response._execute.assert_called_once_with( + "GET", "/v3/rules/rule-123", None, None, None, overrides=None + ) + + def test_find_rule_with_overrides(self, http_client_response): + rules = Rules(http_client_response) + overrides = {"headers": {"X-Test": "value"}} + + rules.find("rule-123", overrides=overrides) + + http_client_response._execute.assert_called_once_with( + "GET", + "/v3/rules/rule-123", + None, + None, + None, + overrides=overrides, + ) + + def test_update_rule(self, http_client_response): + rules = Rules(http_client_response) + request_body = { + "enabled": False, + "actions": [{"type": "archive"}], + } + + rules.update("rule-123", request_body) + + http_client_response._execute.assert_called_once_with( + "PUT", "/v3/rules/rule-123", None, None, request_body, overrides=None + ) + + def test_update_rule_with_overrides(self, http_client_response): + rules = Rules(http_client_response) + request_body = { + "enabled": False, + } + overrides = {"headers": {"X-Test": "value"}, "timeout": 42} + + rules.update("rule-123", request_body, overrides=overrides) + + http_client_response._execute.assert_called_once_with( + "PUT", + "/v3/rules/rule-123", + None, + None, + request_body, + overrides=overrides, + ) + + def test_destroy_rule(self, http_client_delete_response): + rules = Rules(http_client_delete_response) + + rules.destroy("rule-123") + + http_client_delete_response._execute.assert_called_once_with( + "DELETE", "/v3/rules/rule-123", None, None, None, overrides=None + ) + + def test_destroy_rule_with_overrides(self, http_client_delete_response): + rules = Rules(http_client_delete_response) + overrides = {"headers": {"X-Test": "value"}} + + rules.destroy("rule-123", overrides=overrides) + + http_client_delete_response._execute.assert_called_once_with( + "DELETE", + "/v3/rules/rule-123", + None, + None, + None, + overrides=overrides, + ) + + def test_list_rule_evaluations(self, http_client_list_response): + rules = Rules(http_client_list_response) + + rules.list_evaluations("grant-123") + + http_client_list_response._execute.assert_called_once_with( + "GET", "/v3/grants/grant-123/rule-evaluations", None, None, None, overrides=None + ) + + def test_list_rule_evaluations_with_query_params(self, http_client_list_response): + rules = Rules(http_client_list_response) + + rules.list_evaluations( + "grant-123", query_params={"limit": 5, "page_token": "cursor-token"} + ) + + http_client_list_response._execute.assert_called_once_with( + "GET", + "/v3/grants/grant-123/rule-evaluations", + None, + {"limit": 5, "page_token": "cursor-token"}, + None, + overrides=None, + ) + + def test_list_rule_evaluations_with_overrides(self, http_client_list_response): + rules = Rules(http_client_list_response) + overrides = {"headers": {"X-Test": "value"}} + + rules.list_evaluations("grant-123", overrides=overrides) + + http_client_list_response._execute.assert_called_once_with( + "GET", + "/v3/grants/grant-123/rule-evaluations", + None, + None, + None, + overrides=overrides, + ) diff --git a/tests/test_client.py b/tests/test_client.py index dd01c93..b06769f 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -12,6 +12,7 @@ from nylas.resources.grants import Grants from nylas.resources.messages import Messages from nylas.resources.policies import Policies +from nylas.resources.rules import Rules from nylas.resources.threads import Threads from nylas.resources.transactional_send import TransactionalSend from nylas.resources.webhooks import Webhooks @@ -90,6 +91,10 @@ def test_client_messages_property(self, client): assert client.messages is not None assert type(client.messages) is Messages + def test_client_rules_property(self, client): + assert client.rules is not None + assert type(client.rules) is Rules + def test_client_threads_property(self, client): assert client.threads is not None assert type(client.threads) is Threads