From e8b923ab74ffff2b326c2280cb6c1f031edd52ba Mon Sep 17 00:00:00 2001 From: Aaron de Mello Date: Thu, 11 Jun 2026 19:13:46 -0400 Subject: [PATCH 1/7] TW-5373 Add admin API SDK resources --- CHANGELOG.md | 11 +- lib/nylas.rb | 4 + lib/nylas/client.rb | 28 +++ lib/nylas/resources/applications.rb | 12 ++ lib/nylas/resources/domains.rb | 124 ++++++++++++ lib/nylas/resources/policies.rb | 124 ++++++++++++ lib/nylas/resources/redirect_uris.rb | 4 +- lib/nylas/resources/rules.rb | 161 ++++++++++++++++ lib/nylas/resources/workspaces.rb | 110 +++++++++++ spec/nylas/resources/applications_spec.rb | 35 +++- spec/nylas/resources/domains_spec.rb | 167 +++++++++++++++++ spec/nylas/resources/policies_spec.rb | 135 ++++++++++++++ spec/nylas/resources/redirect_uris_spec.rb | 2 +- spec/nylas/resources/rules_spec.rb | 207 +++++++++++++++++++++ spec/nylas/resources/workspaces_spec.rb | 153 +++++++++++++++ 15 files changed, 1271 insertions(+), 6 deletions(-) create mode 100644 lib/nylas/resources/domains.rb create mode 100644 lib/nylas/resources/policies.rb create mode 100644 lib/nylas/resources/rules.rb create mode 100644 lib/nylas/resources/workspaces.rb create mode 100644 spec/nylas/resources/domains_spec.rb create mode 100644 spec/nylas/resources/policies_spec.rb create mode 100644 spec/nylas/resources/rules_spec.rb create mode 100644 spec/nylas/resources/workspaces_spec.rb diff --git a/CHANGELOG.md b/CHANGELOG.md index 9ac92501..1ced35a2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +### Unreleased +* Added Policies resource for managing application policies +* Added Rules resource for managing inbox rules and listing rule evaluations +* Added Workspaces resource for managing workspaces, auto-grouping, and manual assignment +* Added Domains resource for managing (admin) domains, including info and verify operations +* Added Applications update support (PATCH /v3/applications) +* Corrected RedirectUris update verb from PUT to PATCH +* Fixed HTTParty content type issue when request body is nil - POST, PUT, PATCH, and DELETE now default to empty object to ensure Content-Type: application/json is sent (#536) +* Added support for request_body parameter on DELETE (e.g. cancellation_reason for bookings) (#536) + ### [6.7.1] * Fix large attachment handling with string keys and custom content_ids @@ -378,4 +388,3 @@ * various test cleanups ([Steven Harman](https://github.com/stevenharman)) [full changelog](https://github.com/nylas/nylas-ruby/compare/v1.0.0...v1.1.0) - diff --git a/lib/nylas.rb b/lib/nylas.rb index 8086c8a2..8756fdb5 100644 --- a/lib/nylas.rb +++ b/lib/nylas.rb @@ -29,6 +29,10 @@ require_relative "nylas/resources/smart_compose" require_relative "nylas/resources/threads" require_relative "nylas/resources/redirect_uris" +require_relative "nylas/resources/policies" +require_relative "nylas/resources/rules" +require_relative "nylas/resources/workspaces" +require_relative "nylas/resources/domains" require_relative "nylas/resources/webhooks" require_relative "nylas/resources/scheduler" diff --git a/lib/nylas/client.rb b/lib/nylas/client.rb index b08bcb82..37449808 100644 --- a/lib/nylas/client.rb +++ b/lib/nylas/client.rb @@ -113,6 +113,34 @@ def threads Threads.new(self) end + # The policy resources for your Nylas application. + # + # @return [Nylas::Policies] Policy resources for your Nylas application. + def policies + Policies.new(self) + end + + # The rule resources for your Nylas application. + # + # @return [Nylas::Rules] Rule resources for your Nylas application. + def rules + Rules.new(self) + end + + # The workspace resources for your Nylas application. + # + # @return [Nylas::Workspaces] Workspace resources for your Nylas application. + def workspaces + Workspaces.new(self) + end + + # The domain resources for your Nylas application. + # + # @return [Nylas::Domains] Domain resources for your Nylas application. + def domains + Domains.new(self) + end + # The webhook resources for your Nylas application. # # @return [Nylas::Webhooks] Webhook resources for your Nylas application. diff --git a/lib/nylas/resources/applications.rb b/lib/nylas/resources/applications.rb index 800e17c3..245a4be9 100644 --- a/lib/nylas/resources/applications.rb +++ b/lib/nylas/resources/applications.rb @@ -8,6 +8,7 @@ module Nylas # Application class Applications < Resource include ApiOperations::Get + include ApiOperations::Patch attr_reader :redirect_uris @@ -23,5 +24,16 @@ def initialize(sdk_instance) def get_details get(path: "#{api_uri}/v3/applications") end + + # Update application details. + # + # @param request_body [Hash] The values to update the application with. + # @return [Array(Hash, String)] The updated application details and API Request ID. + def update(request_body:) + patch( + path: "#{api_uri}/v3/applications", + request_body: request_body + ) + end end end diff --git a/lib/nylas/resources/domains.rb b/lib/nylas/resources/domains.rb new file mode 100644 index 00000000..0b6ace6c --- /dev/null +++ b/lib/nylas/resources/domains.rb @@ -0,0 +1,124 @@ +# frozen_string_literal: true + +require_relative "resource" +require_relative "../handler/api_operations" + +module Nylas + # Module representing the possible 'type' values in a domain verification attempt. + # @see https://developer.nylas.com/docs/api/v3/admin/#tag--Manage-Domains + module DomainVerificationType + OWNERSHIP = "ownership" + MX = "mx" + SPF = "spf" + DKIM = "dkim" + FEEDBACK = "feedback" + DMARC = "dmarc" + ARC = "arc" + end + + # Module representing the possible 'status' values in a domain verification result. + module DomainVerificationStatus + PENDING = "pending" + DONE = "done" + FAILED = "failed" + end + + # Nylas Manage Domains API + class Domains < Resource + include ApiOperations::Get + include ApiOperations::Post + include ApiOperations::Put + include ApiOperations::Delete + + # Return all domains for the caller's organization. + # + # @param query_params [Hash, nil] Query params to pass to the request. + # Supported keys: `domain` (filter by exact domain address), `region`, `limit`, `page_token`. + # @return [Array(Array(Hash), String, String, Hash)] + # The list of domains, API Request ID, next cursor, and response headers. + def list(query_params: nil) + get_list( + path: "#{api_uri}/v3/admin/domains", + query_params: query_params + ) + end + + # Return a domain. + # + # @param domain_id [String] The identifier of the domain to return. + # Accepts either a UUID or a domain address (FQDN/email format). + # @return [Array(Hash, String, Hash)] The domain, API request ID, and response headers. + def find(domain_id:) + get( + path: "#{api_uri}/v3/admin/domains/#{domain_id}" + ) + end + + # Create a domain. + # + # @param request_body [Hash] The values to create the domain with. + # Requires `name` and `domain_address`. + # @return [Array(Hash, String, Hash)] The created domain, API Request ID, and response headers. + def create(request_body:) + post( + path: "#{api_uri}/v3/admin/domains", + request_body: request_body + ) + end + + # Update a domain. + # + # @param domain_id [String] The identifier of the domain to update. + # Accepts either a UUID or a domain address (FQDN/email format). + # @param request_body [Hash] The values to update the domain with. + # The response echoes only the updated fields, not a full domain object. + # @return [Array(Hash, String)] The updated domain fields and API Request ID. + def update(domain_id:, request_body:) + put( + path: "#{api_uri}/v3/admin/domains/#{domain_id}", + request_body: request_body + ) + end + + # Delete a domain. + # + # @param domain_id [String] The identifier of the domain to delete. + # Accepts either a UUID or a domain address (FQDN/email format). + # @return [Array(TrueClass, String)] True and the API Request ID for the delete operation. + def destroy(domain_id:) + _, request_id = delete( + path: "#{api_uri}/v3/admin/domains/#{domain_id}" + ) + + [true, request_id] + end + + # Get the DNS record info for a domain verification type. + # + # @param domain_id [String] The identifier of the domain. + # Accepts either a UUID or a domain address (FQDN/email format). + # @param request_body [Hash] The verification attempt values. Requires `type`. + # @return [Array(Hash, String, Hash)] + # The domain verification result, API Request ID, and response headers. + def info(domain_id:, request_body:) + post( + path: "#{api_uri}/v3/admin/domains/#{domain_id}/info", + request_body: request_body + ) + end + + # Trigger a DNS verification check for a domain verification type. + # + # @param domain_id [String] The identifier of the domain. + # Accepts either a UUID or a domain address (FQDN/email format). + # @param request_body [Hash] The verification attempt values. Requires `type`. + # @return [Array(Hash, String, Hash)] + # The domain verification result, API Request ID, and response headers. + def verify(domain_id:, request_body:) + post( + path: "#{api_uri}/v3/admin/domains/#{domain_id}/verify", + request_body: request_body + ) + end + end +end diff --git a/lib/nylas/resources/policies.rb b/lib/nylas/resources/policies.rb new file mode 100644 index 00000000..954bea5a --- /dev/null +++ b/lib/nylas/resources/policies.rb @@ -0,0 +1,124 @@ +# frozen_string_literal: true + +require_relative "resource" +require_relative "../handler/api_operations" + +module Nylas + # Nylas Policies API (beta) + # + # Policies define message limits, spam-detection settings, options, and linked + # rules for Nylas Agent Accounts. `application_id` and `organization_id` are + # derived from the API key / gateway headers and are read-only. + # + # Policy objects (the Hash returned/accepted by these methods) carry these keys: + # - +id+ [String] Policy UUID. Read-only; server-assigned on create. + # - +name+ [String] 1-256 chars. Required on create. + # - +application_id+ [String] Read-only; derived from the API key. + # - +organization_id+ [String] Read-only; derived from the API key. + # - +rules+ [Array] Linked rule IDs. + # - +created_at+ [Integer] Unix timestamp (seconds). Read-only. + # - +updated_at+ [Integer] Unix timestamp (seconds). Read-only. + # - +limits+ [Hash] Per-policy limits. Returned as *effective* values resolved + # against the org's billing plan, which may differ from what was sent. Keys: + # - +limit_attachment_size_limit+ [Integer] Bytes; >= 0, <= plan max. + # - +limit_attachment_count_limit+ [Integer] >= 0, <= plan max. + # - +limit_attachment_allowed_types+ [Array] MIME types from the plan allow-list. + # - +limit_size_total_mime+ [Integer] Bytes; >= 0, <= plan max. + # - +limit_storage_total+ [Integer] Bytes. Unlimited-capable: -1 = unlimited. + # - +limit_count_daily_message_received+ [Integer] Per-grant daily received-message + # cap. Unlimited-capable: -1 = unlimited. + # - +limit_count_daily_email_sent+ [Integer] Per-grant daily sent-email cap. + # Unlimited-capable: -1 = unlimited. + # - +limit_inbox_retention_period+ [Integer] Days. Unlimited-capable: -1. Must be + # greater than spam retention when both set. + # - +limit_spam_retention_period+ [Integer] Days. Unlimited-capable: -1. Must be + # shorter than inbox retention when both set. + # - +options+ [Hash] Policy options. Keys: + # - +additional_folders+ [Array] Only allowed when the plan permits. + # - +use_cidr_aliasing+ [Boolean] Only allowed when the plan permits. + # - +spam_detection+ [Hash] Spam-detection settings. Keys: + # - +use_list_dnsbl+ [Boolean] Always present in responses (false when unset). + # - +use_header_anomaly_detection+ [Boolean] Always present in responses (false when unset). + # - +spam_sensitivity+ [Float] 0.1-5.0 inclusive. Default 1.0. + # + # The unlimited sentinel for unlimited-capable fields is -1 only; values < -1 are + # rejected, and -1 is honored only when the plan permits unlimited for that field. + class Policies < Resource + include ApiOperations::Get + include ApiOperations::Post + include ApiOperations::Put + include ApiOperations::Delete + + # Return all policies. + # + # The list envelope is flat: the data array is the policies themselves and + # +next_cursor+ is a top-level sibling. +next_cursor+ is present on every + # non-empty page (including the last) and is not a has-more flag; page until + # an empty data array is returned. + # + # @param query_params [Hash, nil] Query params to pass to the request + # (e.g. +limit+ — default 10, no server max; +page_token+ — opaque cursor). + # @return [Array(Array(Hash), String, String, Hash)] The list of policies, + # API Request ID, next cursor, and response headers. + def list(query_params: nil) + get_list( + path: "#{api_uri}/v3/policies", + query_params: query_params + ) + end + + # Return a policy. + # + # @param policy_id [String] The id of the policy to return. + # @return [Array(Hash, String, Hash)] The policy, API request ID, and response headers. + def find(policy_id:) + get( + path: "#{api_uri}/v3/policies/#{policy_id}" + ) + end + + # Create a policy. + # + # @param request_body [Hash] The values to create the policy with. Honored keys: + # +name+ (required), +options+, +limits+, +rules+, +spam_detection+. Any + # +id+/+created_at+/+updated_at+/+application_id+/+organization_id+ are ignored. + # Omitted +limits+/+options+/+spam_detection+ sub-fields fall back to plan defaults. + # @return [Array(Hash, String, Hash)] The created policy, API Request ID, and response headers. + def create(request_body:) + post( + path: "#{api_uri}/v3/policies", + request_body: request_body + ) + end + + # Update a policy. + # + # The route verb is PUT, but the update is a partial nested merge: provided + # sub-objects (+limits+/+options+/+spam_detection+) are merged field-by-field + # onto the stored policy. Send only the fields you intend to change. + # + # @param policy_id [String] The id of the policy to update. + # @param request_body [Hash] The values to update the policy with. Honored keys: + # +name+, +options+, +limits+, +rules+, +spam_detection+. Any + # +id+/+created_at+/+updated_at+/+application_id+/+organization_id+ are ignored. + # @return [Array(Hash, String)] The updated policy and API Request ID. + def update(policy_id:, request_body:) + put( + path: "#{api_uri}/v3/policies/#{policy_id}", + request_body: request_body + ) + end + + # Delete a policy. + # + # @param policy_id [String] The id of the policy to delete. + # @return [Array(TrueClass, String)] True and the API Request ID for the delete operation. + def destroy(policy_id:) + _, request_id = delete( + path: "#{api_uri}/v3/policies/#{policy_id}" + ) + + [true, request_id] + end + end +end diff --git a/lib/nylas/resources/redirect_uris.rb b/lib/nylas/resources/redirect_uris.rb index 5976e1ab..8f9653c6 100644 --- a/lib/nylas/resources/redirect_uris.rb +++ b/lib/nylas/resources/redirect_uris.rb @@ -8,7 +8,7 @@ module Nylas class RedirectUris < Resource include ApiOperations::Get include ApiOperations::Post - include ApiOperations::Put + include ApiOperations::Patch include ApiOperations::Delete # Return all redirect uris. @@ -47,7 +47,7 @@ def create(request_body:) # @param request_body [Hash] The values to update the redirect uri with # @return [Array(Hash, String)] The updated redirect uri and API Request ID. def update(redirect_uri_id:, request_body:) - put( + patch( path: "#{api_uri}/v3/applications/redirect-uris/#{redirect_uri_id}", request_body: request_body ) diff --git a/lib/nylas/resources/rules.rb b/lib/nylas/resources/rules.rb new file mode 100644 index 00000000..61e18bee --- /dev/null +++ b/lib/nylas/resources/rules.rb @@ -0,0 +1,161 @@ +# frozen_string_literal: true + +require_relative "resource" +require_relative "../handler/api_operations" + +module Nylas + # Module representing the possible 'trigger' values for a Rule. + module RuleTrigger + INBOUND = "inbound" + OUTBOUND = "outbound" + end + + # Module representing the possible 'match.operator' values for a Rule. + module RuleMatchOperator + ANY = "any" + ALL = "all" + end + + # Module representing the possible condition 'field' values for a Rule. + module RuleConditionField + FROM_ADDRESS = "from.address" + FROM_DOMAIN = "from.domain" + FROM_TLD = "from.tld" + RECIPIENT_ADDRESS = "recipient.address" + RECIPIENT_DOMAIN = "recipient.domain" + RECIPIENT_TLD = "recipient.tld" + OUTBOUND_TYPE = "outbound.type" + end + + # Module representing the possible condition 'operator' values for a Rule. + module RuleConditionOperator + IS = "is" + IS_NOT = "is_not" + CONTAINS = "contains" + IN_LIST = "in_list" + end + + # Module representing the possible 'outbound.type' condition values for a Rule. + module RuleOutboundType + COMPOSE = "compose" + REPLY = "reply" + end + + # Module representing the possible action 'type' values for a Rule. + module RuleActionType + BLOCK = "block" + MARK_AS_SPAM = "mark_as_spam" + ASSIGN_TO_FOLDER = "assign_to_folder" + MARK_AS_READ = "mark_as_read" + MARK_AS_STARRED = "mark_as_starred" + ARCHIVE = "archive" + TRASH = "trash" + end + + # Module representing the possible 'evaluation_stage' values in a rule evaluation. + module RuleEvaluationStage + SMTP_RCPT = "smtp_rcpt" + INBOX_PROCESSING = "inbox_processing" + OUTBOUND_SEND = "outbound_send" + end + + # Nylas Rules API + class Rules < Resource + include ApiOperations::Get + include ApiOperations::Post + include ApiOperations::Put + include ApiOperations::Delete + + # Return all rules. + # + # The list endpoint returns a nested envelope + # ({ request_id, data: { items: [...], next_cursor } }), so the items and + # cursor are unwrapped here defensively rather than via the standard + # get_list helper, which would mis-read the nested shape. + # + # @param query_params [Hash, nil] Query params to pass to the request. + # @return [Array(Array(Hash), String, String, Hash)] + # The list of rules, API Request ID, next cursor, and response headers. + def list(query_params: nil) + response = get_raw( + path: "#{api_uri}/v3/rules", + query_params: query_params + ) + + data = response[:data] + # Unwrap only when the envelope actually carries an :items key. Go's + # ListWithCursorResult serializes a nil slice as "items": null, so coerce + # that to [] rather than falling back to the envelope hash itself. + items = if data.is_a?(Hash) && data.key?(:items) + data[:items] || [] + else + data + end + next_cursor = data.is_a?(Hash) ? data[:next_cursor] : response[:next_cursor] + + [items, response[:request_id], next_cursor, response[:headers]] + end + + # Return a rule. + # + # @param rule_id [String] The id of the rule to return. + # @return [Array(Hash, String, Hash)] The rule, API request ID, and response headers. + def find(rule_id:) + get( + path: "#{api_uri}/v3/rules/#{rule_id}" + ) + end + + # Create a rule. + # + # @param request_body [Hash] The values to create the rule with. + # @return [Array(Hash, String)] The created rule and API Request ID. + def create(request_body:) + post( + path: "#{api_uri}/v3/rules", + request_body: request_body + ) + end + + # Update a rule. Only the provided fields are changed (partial update). + # + # @param rule_id [String] The id of the rule to update. + # @param request_body [Hash] The values to update the rule with. + # @return [Array(Hash, String)] The updated rule and API Request ID. + def update(rule_id:, request_body:) + put( + path: "#{api_uri}/v3/rules/#{rule_id}", + request_body: request_body + ) + end + + # Delete a rule. + # + # @param rule_id [String] The id of the rule to delete. + # @return [Array(TrueClass, String)] True and the API Request ID for the delete operation. + def destroy(rule_id:) + _, request_id = delete( + path: "#{api_uri}/v3/rules/#{rule_id}" + ) + + [true, request_id] + end + + # Return all rule evaluations for a grant. + # + # This endpoint returns a flat array with no cursor, so the standard + # get_list helper is used (next_cursor is always nil). + # + # @param grant_id [String] The id of the grant to query rule evaluations for. + # @param query_params [Hash, nil] Query params to pass to the request. + # @return [Array(Array(Hash), String, String, Hash)] + # The list of rule evaluations, API Request ID, next cursor (always nil + # for this endpoint), and response headers. + def list_evaluations(grant_id:, query_params: nil) + get_list( + path: "#{api_uri}/v3/grants/#{grant_id}/rule-evaluations", + query_params: query_params + ) + end + end +end diff --git a/lib/nylas/resources/workspaces.rb b/lib/nylas/resources/workspaces.rb new file mode 100644 index 00000000..254a4ecb --- /dev/null +++ b/lib/nylas/resources/workspaces.rb @@ -0,0 +1,110 @@ +# frozen_string_literal: true + +require_relative "resource" +require_relative "../handler/api_operations" + +module Nylas + # Nylas Workspaces API + # + # A workspace groups grants in a Nylas application by email domain. Grants can be + # auto-grouped (by matching email domain) or manually assigned/removed. + class Workspaces < Resource + include ApiOperations::Get + include ApiOperations::Post + include ApiOperations::Patch + include ApiOperations::Delete + + # Return all workspaces for the application. + # + # The list endpoint is not paginated; +data+ is a flat array of workspaces. + # + # @return [Array(Array(Hash), String, Hash)] The list of workspaces, API Request ID, + # and response headers. + def list + get( + path: "#{api_uri}/v3/workspaces" + ) + end + + # Return a workspace. + # + # @param workspace_id [String] The id of the workspace to return. Accepts a workspace + # UUID or an email domain. + # @return [Array(Hash, String, Hash)] The workspace, API Request ID, and response headers. + def find(workspace_id:) + get( + path: "#{api_uri}/v3/workspaces/#{workspace_id}" + ) + end + + # Create a workspace. + # + # @param request_body [Hash] The values to create the workspace with. Only +name+ is + # required. + # @return [Array(Hash, String, Hash)] The created workspace, API Request ID, and + # response headers. + def create(request_body:) + post( + path: "#{api_uri}/v3/workspaces", + request_body: request_body + ) + end + + # Update a workspace. + # + # The API exposes update via PATCH only (there is no PUT route). The workspace must be + # addressed by its UUID; a domain path param is not accepted on update. + # + # @param workspace_id [String] The UUID of the workspace to update. + # @param request_body [Hash] The values to update the workspace with. + # @return [Array(Hash, String)] The updated workspace and API Request ID. + def update(workspace_id:, request_body:) + patch( + path: "#{api_uri}/v3/workspaces/#{workspace_id}", + request_body: request_body + ) + end + + # Delete a workspace. + # + # @param workspace_id [String] The id of the workspace to delete. Accepts a workspace + # UUID or an email domain. + # @return [Array(TrueClass, String)] True and the API Request ID for the delete operation. + def destroy(workspace_id:) + _, request_id = delete( + path: "#{api_uri}/v3/workspaces/#{workspace_id}" + ) + + [true, request_id] + end + + # Auto-group grants into workspaces by matching email domain. + # + # Runs as a background job and returns immediately with a job ID. Rate limited to one + # call per minute per application. + # + # @param request_body [Hash] Optional filters to scope which grants are grouped. + # @return [Array(Hash, String, Hash)] The job info, API Request ID, and response headers. + def auto_group(request_body: nil) + post( + path: "#{api_uri}/v3/workspaces/auto-group", + request_body: request_body + ) + end + + # Manually assign grants to or remove grants from a workspace. + # + # @param workspace_id [String] The id of the workspace to update. Accepts a workspace + # UUID or an email domain. + # @param request_body [Hash] The grants to assign and/or remove (+assign_grants+, + # +remove_grants+). + # @return [Array(Hash, String, Hash)] The assignment result, API Request ID, and + # response headers. + def manual_assign(workspace_id:, request_body:) + post( + path: "#{api_uri}/v3/workspaces/#{workspace_id}/manual-assign", + request_body: request_body + ) + end + end +end diff --git a/spec/nylas/resources/applications_spec.rb b/spec/nylas/resources/applications_spec.rb index 0a37e070..2ebce8f0 100644 --- a/spec/nylas/resources/applications_spec.rb +++ b/spec/nylas/resources/applications_spec.rb @@ -53,9 +53,40 @@ .with(path: path) .and_return(response) - response = application.get_details + application_response = application.get_details - expect(response).to eq(response) + expect(application_response).to eq(response) + end + end + + describe "#update" do + it "calls the patch method with the correct parameters" do + request_body = { + branding: { + name: "My application", + icon_url: "https://my-app.com/my-icon.png", + website_url: "https://my-app.com", + description: "Online banking application." + }, + hosted_authentication: { + background_image_url: "https://my-app.com/bg.jpg", + alignment: "left", + color_primary: "#dc0000", + color_secondary: "#000056", + title: "string", + subtitle: "string", + background_color: "#003400", + spacing: 5 + } + } + path = "#{api_uri}/v3/applications" + allow(application).to receive(:patch) + .with(path: path, request_body: request_body) + .and_return(response) + + application_response = application.update(request_body: request_body) + + expect(application_response).to eq(response) end end end diff --git a/spec/nylas/resources/domains_spec.rb b/spec/nylas/resources/domains_spec.rb new file mode 100644 index 00000000..2225bcd0 --- /dev/null +++ b/spec/nylas/resources/domains_spec.rb @@ -0,0 +1,167 @@ +# frozen_string_literal: true + +describe Nylas::Domains do + let(:domains) { described_class.new(client) } + let(:response) do + [{ + id: "f9d3c1b2-1a2b-4c3d-8e4f-1234567890ab", + name: "Marketing domain", + domain_address: "mail.example.com", + organization_id: "org-123", + branded: false, + region: "us", + verified_ownership: false, + verified_mx: false, + verified_spf: false, + verified_feedback: false, + verified_dkim: false, + verified_dmarc: false, + verified_arc: false, + created_at: 1234567890, + updated_at: 1234567890 + }, "mock_request_id"] + end + + describe "#list" do + let(:list_response) do + [[response[0]], response[1], "mock_next_cursor"] + end + + it "calls the get_list method with the correct parameters" do + path = "#{api_uri}/v3/admin/domains" + allow(domains).to receive(:get_list) + .with(path: path, query_params: nil) + .and_return(list_response) + + domains_response = domains.list + + expect(domains_response).to eq(list_response) + end + + it "calls the get_list method with the correct parameters and query params" do + query_params = { domain: "mail.example.com", region: "us" } + path = "#{api_uri}/v3/admin/domains" + allow(domains).to receive(:get_list) + .with(path: path, query_params: query_params) + .and_return(list_response) + + domains_response = domains.list(query_params: query_params) + + expect(domains_response).to eq(list_response) + end + end + + describe "#find" do + it "calls the get method with the correct parameters" do + domain_id = "f9d3c1b2-1a2b-4c3d-8e4f-1234567890ab" + path = "#{api_uri}/v3/admin/domains/#{domain_id}" + allow(domains).to receive(:get) + .with(path: path) + .and_return(response) + + domain_response = domains.find(domain_id: domain_id) + + expect(domain_response).to eq(response) + end + + it "accepts a domain address as the identifier" do + domain_id = "mail.example.com" + path = "#{api_uri}/v3/admin/domains/#{domain_id}" + allow(domains).to receive(:get) + .with(path: path) + .and_return(response) + + domain_response = domains.find(domain_id: domain_id) + + expect(domain_response).to eq(response) + end + end + + describe "#create" do + it "calls the post method with the correct parameters" do + request_body = { + name: "Marketing domain", + domain_address: "mail.example.com" + } + path = "#{api_uri}/v3/admin/domains" + allow(domains).to receive(:post) + .with(path: path, request_body: request_body) + .and_return(response) + + domain_response = domains.create(request_body: request_body) + + expect(domain_response).to eq(response) + end + end + + describe "#update" do + it "calls the put method with the correct parameters" do + domain_id = "f9d3c1b2-1a2b-4c3d-8e4f-1234567890ab" + request_body = { name: "Renamed domain" } + path = "#{api_uri}/v3/admin/domains/#{domain_id}" + allow(domains).to receive(:put) + .with(path: path, request_body: request_body) + .and_return([{ name: "Renamed domain", updated_at: 1234567890 }, "mock_request_id"]) + + domain_response = domains.update(domain_id: domain_id, request_body: request_body) + + expect(domain_response).to eq([{ name: "Renamed domain", updated_at: 1234567890 }, "mock_request_id"]) + end + end + + describe "#destroy" do + it "calls the delete method with the correct parameters" do + domain_id = "f9d3c1b2-1a2b-4c3d-8e4f-1234567890ab" + path = "#{api_uri}/v3/admin/domains/#{domain_id}" + allow(domains).to receive(:delete) + .with(path: path) + .and_return([true, "mock_request_id"]) + + domain_response = domains.destroy(domain_id: domain_id) + + expect(domain_response).to eq([true, "mock_request_id"]) + end + end + + describe "#info" do + it "calls the post method with the correct parameters" do + domain_id = "f9d3c1b2-1a2b-4c3d-8e4f-1234567890ab" + request_body = { type: "ownership" } + path = "#{api_uri}/v3/admin/domains/#{domain_id}/info" + result = [{ + domain_id: domain_id, + attempt: { type: "ownership", + options: { host: "example.com", type: "TXT", value: "nylas-verify=abc" } }, + status: "pending", + message: "Please configure the TXT record." + }, "mock_request_id"] + allow(domains).to receive(:post) + .with(path: path, request_body: request_body) + .and_return(result) + + domain_response = domains.info(domain_id: domain_id, request_body: request_body) + + expect(domain_response).to eq(result) + end + end + + describe "#verify" do + it "calls the post method with the correct parameters" do + domain_id = "f9d3c1b2-1a2b-4c3d-8e4f-1234567890ab" + request_body = { type: "dkim" } + path = "#{api_uri}/v3/admin/domains/#{domain_id}/verify" + result = [{ + domain_id: domain_id, + attempt: { type: "dkim" }, + status: "done" + }, "mock_request_id"] + allow(domains).to receive(:post) + .with(path: path, request_body: request_body) + .and_return(result) + + domain_response = domains.verify(domain_id: domain_id, request_body: request_body) + + expect(domain_response).to eq(result) + end + end +end diff --git a/spec/nylas/resources/policies_spec.rb b/spec/nylas/resources/policies_spec.rb new file mode 100644 index 00000000..2a6871fa --- /dev/null +++ b/spec/nylas/resources/policies_spec.rb @@ -0,0 +1,135 @@ +# frozen_string_literal: true + +describe Nylas::Policies do + let(:policies) { described_class.new(client) } + let(:response) do + [{ + id: "policy-123", + name: "Agent default policy", + application_id: "app-123", + organization_id: "org-123", + rules: ["rule-123"], + limits: { + limit_attachment_size_limit: 26_214_400, + limit_attachment_count_limit: 25, + limit_attachment_allowed_types: ["image/png", "application/pdf"], + limit_size_total_mime: 52_428_800, + limit_storage_total: -1, + limit_count_daily_message_received: 1000, + limit_count_daily_email_sent: 500, + limit_inbox_retention_period: 30, + limit_spam_retention_period: 7 + }, + options: { + additional_folders: ["Archive"], + use_cidr_aliasing: false + }, + spam_detection: { + use_list_dnsbl: false, + use_header_anomaly_detection: false, + spam_sensitivity: 1.0 + }, + created_at: 1_234_567_890, + updated_at: 1_234_567_890 + }, "mock_request_id"] + end + + describe "#list" do + let(:list_response) do + [[response[0]], response[1], "mock_next_cursor"] + end + + it "calls the get_list method with the correct parameters" do + path = "#{api_uri}/v3/policies" + allow(policies).to receive(:get_list) + .with(path: path, query_params: nil) + .and_return(list_response) + + policies_response = policies.list(query_params: nil) + + expect(policies_response).to eq(list_response) + end + + it "calls the get_list method with the correct parameters and query params" do + query_params = { limit: 10, page_token: "cursor-abc" } + path = "#{api_uri}/v3/policies" + allow(policies).to receive(:get_list) + .with(path: path, query_params: query_params) + .and_return(list_response) + + policies_response = policies.list(query_params: query_params) + + expect(policies_response).to eq(list_response) + end + end + + describe "#find" do + it "calls the get method with the correct parameters" do + policy_id = "policy-123" + path = "#{api_uri}/v3/policies/#{policy_id}" + allow(policies).to receive(:get) + .with(path: path) + .and_return(response) + + policy_response = policies.find(policy_id: policy_id) + + expect(policy_response).to eq(response) + end + end + + describe "#create" do + it "calls the post method with the correct parameters" do + request_body = { + name: "Agent default policy", + rules: ["rule-123"], + limits: { + limit_count_daily_message_received: 1000, + limit_count_daily_email_sent: 500, + limit_storage_total: -1 + }, + options: { use_cidr_aliasing: false }, + spam_detection: { spam_sensitivity: 1.0 } + } + path = "#{api_uri}/v3/policies" + allow(policies).to receive(:post) + .with(path: path, request_body: request_body) + .and_return(response) + + policy_response = policies.create(request_body: request_body) + + expect(policy_response).to eq(response) + end + end + + describe "#update" do + it "calls the put method with the correct parameters" do + policy_id = "policy-123" + request_body = { + name: "Updated policy", + limits: { limit_count_daily_email_sent: 750 } + } + path = "#{api_uri}/v3/policies/#{policy_id}" + allow(policies).to receive(:put) + .with(path: path, request_body: request_body) + .and_return(response) + + policy_response = policies.update(policy_id: policy_id, request_body: request_body) + + expect(policy_response).to eq(response) + end + end + + describe "#destroy" do + it "calls the delete method with the correct parameters" do + policy_id = "policy-123" + path = "#{api_uri}/v3/policies/#{policy_id}" + allow(policies).to receive(:delete) + .with(path: path) + .and_return([true, "mock_request_id"]) + + policy_response = policies.destroy(policy_id: policy_id) + + expect(policy_response).to eq([true, "mock_request_id"]) + end + end +end diff --git a/spec/nylas/resources/redirect_uris_spec.rb b/spec/nylas/resources/redirect_uris_spec.rb index a63c16c4..e5a89720 100644 --- a/spec/nylas/resources/redirect_uris_spec.rb +++ b/spec/nylas/resources/redirect_uris_spec.rb @@ -87,7 +87,7 @@ } } path = "#{api_uri}/v3/applications/redirect-uris/#{redirect_uri_id}" - allow(redirect_uris).to receive(:put) + allow(redirect_uris).to receive(:patch) .with(path: path, request_body: request_body) .and_return(response) diff --git a/spec/nylas/resources/rules_spec.rb b/spec/nylas/resources/rules_spec.rb new file mode 100644 index 00000000..4fbc19fd --- /dev/null +++ b/spec/nylas/resources/rules_spec.rb @@ -0,0 +1,207 @@ +# frozen_string_literal: true + +describe Nylas::Rules do + let(:rules) { described_class.new(client) } + let(:rule) do + { + id: "rule-123", + name: "Block spammers", + description: "Block messages from a domain", + priority: 10, + enabled: true, + trigger: "inbound", + match: { + operator: "all", + conditions: [ + { field: "from.domain", operator: "is", value: "spam.example.com" } + ] + }, + actions: [ + { type: "block" } + ], + application_id: "app-123", + organization_id: "org-123", + created_at: 1234567890, + updated_at: 1234567890 + } + end + + describe "#list" do + it "unwraps the nested list envelope and returns items, request id, next cursor, and headers" do + path = "#{api_uri}/v3/rules" + query_params = { limit: 10 } + raw_response = { + request_id: "req-123", + data: { + items: [rule], + next_cursor: "cursor-abc" + }, + headers: { "x-request-id" => "req-123" } + } + allow(rules).to receive(:get_raw) + .with(path: path, query_params: query_params) + .and_return(raw_response) + + result = rules.list(query_params: query_params) + + expect(result).to eq([[rule], "req-123", "cursor-abc", { "x-request-id" => "req-123" }]) + end + + it "omits next_cursor when the nested envelope has none" do + path = "#{api_uri}/v3/rules" + raw_response = { + request_id: "req-123", + data: { items: [rule] } + } + allow(rules).to receive(:get_raw) + .with(path: path, query_params: nil) + .and_return(raw_response) + + result = rules.list + + expect(result).to eq([[rule], "req-123", nil, nil]) + end + + it "coerces a null items slice to an empty array (Go nil-slice marshals as items: null)" do + path = "#{api_uri}/v3/rules" + raw_response = { + request_id: "req-123", + data: { items: nil, next_cursor: nil } + } + allow(rules).to receive(:get_raw) + .with(path: path, query_params: nil) + .and_return(raw_response) + + result = rules.list + + expect(result).to eq([[], "req-123", nil, nil]) + end + + it "falls back gracefully when data is a flat array" do + path = "#{api_uri}/v3/rules" + raw_response = { + request_id: "req-123", + data: [rule], + next_cursor: "cursor-flat" + } + allow(rules).to receive(:get_raw) + .with(path: path, query_params: nil) + .and_return(raw_response) + + result = rules.list + + expect(result).to eq([[rule], "req-123", "cursor-flat", nil]) + end + end + + describe "#find" do + it "calls the get method with the correct parameters" do + rule_id = "rule-123" + path = "#{api_uri}/v3/rules/#{rule_id}" + allow(rules).to receive(:get) + .with(path: path) + .and_return([rule, "req-123"]) + + result = rules.find(rule_id: rule_id) + + expect(result).to eq([rule, "req-123"]) + end + end + + describe "#create" do + it "calls the post method with the correct parameters" do + request_body = { + name: "Block spammers", + match: { + conditions: [ + { field: "from.domain", operator: "is", value: "spam.example.com" } + ] + }, + actions: [{ type: "block" }] + } + path = "#{api_uri}/v3/rules" + allow(rules).to receive(:post) + .with(path: path, request_body: request_body) + .and_return([rule, "req-123"]) + + result = rules.create(request_body: request_body) + + expect(result).to eq([rule, "req-123"]) + end + end + + describe "#update" do + it "calls the put method with the correct parameters" do + rule_id = "rule-123" + request_body = { enabled: false } + path = "#{api_uri}/v3/rules/#{rule_id}" + allow(rules).to receive(:put) + .with(path: path, request_body: request_body) + .and_return([rule, "req-123"]) + + result = rules.update(rule_id: rule_id, request_body: request_body) + + expect(result).to eq([rule, "req-123"]) + end + end + + describe "#destroy" do + it "calls the delete method with the correct parameters" do + rule_id = "rule-123" + path = "#{api_uri}/v3/rules/#{rule_id}" + allow(rules).to receive(:delete) + .with(path: path) + .and_return([nil, "req-123"]) + + result = rules.destroy(rule_id: rule_id) + + expect(result).to eq([true, "req-123"]) + end + end + + describe "#list_evaluations" do + let(:evaluation) do + { + id: "eval-123", + grant_id: "grant-123", + message_id: nil, + evaluated_at: 1234567890, + evaluation_stage: "inbox_processing", + evaluation_input: { from_address: "sender@example.com" }, + applied_actions: { blocked: true }, + matched_rule_ids: ["rule-123"], + application_id: "app-123", + organization_id: "org-123", + created_at: 1234567890, + updated_at: 1234567890 + } + end + + it "calls the get_list method with the correct parameters" do + grant_id = "grant-123" + path = "#{api_uri}/v3/grants/#{grant_id}/rule-evaluations" + query_params = { limit: 10 } + list_response = [[evaluation], "req-123", nil, {}] + allow(rules).to receive(:get_list) + .with(path: path, query_params: query_params) + .and_return(list_response) + + result = rules.list_evaluations(grant_id: grant_id, query_params: query_params) + + expect(result).to eq(list_response) + end + + it "passes nil query params when none are provided" do + grant_id = "grant-123" + path = "#{api_uri}/v3/grants/#{grant_id}/rule-evaluations" + list_response = [[evaluation], "req-123", nil, {}] + allow(rules).to receive(:get_list) + .with(path: path, query_params: nil) + .and_return(list_response) + + result = rules.list_evaluations(grant_id: grant_id) + + expect(result).to eq(list_response) + end + end +end diff --git a/spec/nylas/resources/workspaces_spec.rb b/spec/nylas/resources/workspaces_spec.rb new file mode 100644 index 00000000..0ffef142 --- /dev/null +++ b/spec/nylas/resources/workspaces_spec.rb @@ -0,0 +1,153 @@ +# frozen_string_literal: true + +describe Nylas::Workspaces do + let(:workspaces) { described_class.new(client) } + let(:response) do + { + workspace_id: "5967ca40-1234-4321-abcd-1234567890ab", + application_id: "abc12345-1234-4321-abcd-1234567890ab", + name: "Acme Engineering", + domain: "acme.com", + auto_group: true, + created_at: 1_234_567_890, + updated_at: 1_234_567_890 + } + end + + describe "#list" do + it "calls the get method with the correct path" do + path = "#{api_uri}/v3/workspaces" + list_response = [[response], "mock_request_id", {}] + allow(workspaces).to receive(:get) + .with(path: path) + .and_return(list_response) + + workspaces_response = workspaces.list + + expect(workspaces).to have_received(:get).with(path: path) + expect(workspaces_response).to eq(list_response) + end + end + + describe "#find" do + it "calls the get method with the correct path" do + workspace_id = "workspace-123" + path = "#{api_uri}/v3/workspaces/#{workspace_id}" + allow(workspaces).to receive(:get) + .with(path: path) + .and_return(response) + + workspace_response = workspaces.find(workspace_id: workspace_id) + + expect(workspaces).to have_received(:get).with(path: path) + expect(workspace_response).to eq(response) + end + end + + describe "#create" do + it "calls the post method with the correct path and body" do + request_body = { + name: "Acme Engineering", + domain: "acme.com", + auto_group: true, + policy_id: "policy-123", + rules_ids: ["rule-123"] + } + path = "#{api_uri}/v3/workspaces" + allow(workspaces).to receive(:post) + .with(path: path, request_body: request_body) + .and_return(response) + + workspace_response = workspaces.create(request_body: request_body) + + expect(workspaces).to have_received(:post).with(path: path, request_body: request_body) + expect(workspace_response).to eq(response) + end + end + + describe "#update" do + it "calls the patch method with the correct path and body" do + workspace_id = "workspace-123" + request_body = { name: "Renamed Workspace", policy_id: "policy-456" } + path = "#{api_uri}/v3/workspaces/#{workspace_id}" + allow(workspaces).to receive(:patch) + .with(path: path, request_body: request_body) + .and_return([response, "mock_request_id"]) + + workspace_response = workspaces.update(workspace_id: workspace_id, request_body: request_body) + + expect(workspaces).to have_received(:patch).with(path: path, request_body: request_body) + expect(workspace_response).to eq([response, "mock_request_id"]) + end + end + + describe "#destroy" do + it "calls the delete method with the correct path" do + workspace_id = "workspace-123" + path = "#{api_uri}/v3/workspaces/#{workspace_id}" + allow(workspaces).to receive(:delete) + .with(path: path) + .and_return([nil, "mock_request_id"]) + + workspace_response = workspaces.destroy(workspace_id: workspace_id) + + expect(workspaces).to have_received(:delete).with(path: path) + expect(workspace_response).to eq([true, "mock_request_id"]) + end + end + + describe "#auto_group" do + it "calls the post method with the correct path and body" do + request_body = { specific_domain: "acme.com", invalid_also: false } + path = "#{api_uri}/v3/workspaces/auto-group" + job_response = { + job_id: "job-123", + message: "Auto-grouping started successfully under JobID 'job-123'." + } + allow(workspaces).to receive(:post) + .with(path: path, request_body: request_body) + .and_return(job_response) + + workspace_response = workspaces.auto_group(request_body: request_body) + + expect(workspaces).to have_received(:post).with(path: path, request_body: request_body) + expect(workspace_response).to eq(job_response) + end + + it "defaults the request body to nil when not provided" do + path = "#{api_uri}/v3/workspaces/auto-group" + allow(workspaces).to receive(:post) + .with(path: path, request_body: nil) + + workspaces.auto_group + + expect(workspaces).to have_received(:post).with(path: path, request_body: nil) + end + end + + describe "#manual_assign" do + it "calls the post method with the correct path and body" do + workspace_id = "workspace-123" + request_body = { + assign_grants: ["grant-123"], + remove_grants: ["grant-456"] + } + path = "#{api_uri}/v3/workspaces/#{workspace_id}/manual-assign" + assign_response = { + application_id: "abc12345-1234-4321-abcd-1234567890ab", + workspace_id: workspace_id, + domain: "acme.com", + grants_assigned: ["grant-123"], + grants_removed: ["grant-456"] + } + allow(workspaces).to receive(:post) + .with(path: path, request_body: request_body) + .and_return(assign_response) + + workspace_response = workspaces.manual_assign(workspace_id: workspace_id, request_body: request_body) + + expect(workspaces).to have_received(:post).with(path: path, request_body: request_body) + expect(workspace_response).to eq(assign_response) + end + end +end From 7d2da77bf5dbcd637762ecf891e158ab6f7526fa Mon Sep 17 00:00:00 2001 From: Aaron de Mello Date: Thu, 11 Jun 2026 19:38:22 -0400 Subject: [PATCH 2/7] TW-5373 Align workspace schema with source --- CHANGELOG.md | 2 +- spec/nylas/resources/workspaces_spec.rb | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ced35a2..6ba8b9fe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,7 +3,7 @@ ### Unreleased * Added Policies resource for managing application policies * Added Rules resource for managing inbox rules and listing rule evaluations -* Added Workspaces resource for managing workspaces, auto-grouping, and manual assignment +* Added Workspaces resource for managing workspaces, auto-grouping, manual assignment, `default`, `policy_id`, and `rule_ids` * Added Domains resource for managing (admin) domains, including info and verify operations * Added Applications update support (PATCH /v3/applications) * Corrected RedirectUris update verb from PUT to PATCH diff --git a/spec/nylas/resources/workspaces_spec.rb b/spec/nylas/resources/workspaces_spec.rb index 0ffef142..7a161f19 100644 --- a/spec/nylas/resources/workspaces_spec.rb +++ b/spec/nylas/resources/workspaces_spec.rb @@ -51,7 +51,7 @@ domain: "acme.com", auto_group: true, policy_id: "policy-123", - rules_ids: ["rule-123"] + rule_ids: ["rule-123"] } path = "#{api_uri}/v3/workspaces" allow(workspaces).to receive(:post) From 68afceb986902a6087e4c2f9cefddef714a2c20e Mon Sep 17 00:00:00 2001 From: Aaron de Mello Date: Fri, 12 Jun 2026 10:06:51 -0400 Subject: [PATCH 3/7] Add lists admin API support --- CHANGELOG.md | 3 +- lib/nylas.rb | 1 + lib/nylas/client.rb | 8 +++ lib/nylas/handler/api_operations.rb | 20 ++++--- lib/nylas/resources/domains.rb | 83 +++++++++++++++++++++++----- lib/nylas/resources/lists.rb | 36 ++++++++++++ spec/nylas/client_spec.rb | 1 + spec/nylas/resources/domains_spec.rb | 56 +++++++++++++------ spec/nylas/resources/lists_spec.rb | 44 +++++++++++++++ 9 files changed, 211 insertions(+), 41 deletions(-) create mode 100644 lib/nylas/resources/lists.rb create mode 100644 spec/nylas/resources/lists_spec.rb diff --git a/CHANGELOG.md b/CHANGELOG.md index 6ba8b9fe..9fab52e3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,8 +3,9 @@ ### Unreleased * Added Policies resource for managing application policies * Added Rules resource for managing inbox rules and listing rule evaluations +* Added Lists resource support for creating application lists * Added Workspaces resource for managing workspaces, auto-grouping, manual assignment, `default`, `policy_id`, and `rule_ids` -* Added Domains resource for managing (admin) domains, including info and verify operations +* Added Domains resource for signed Service Account Manage Domains requests, including info and verify operations * Added Applications update support (PATCH /v3/applications) * Corrected RedirectUris update verb from PUT to PATCH * Fixed HTTParty content type issue when request body is nil - POST, PUT, PATCH, and DELETE now default to empty object to ensure Content-Type: application/json is sent (#536) diff --git a/lib/nylas.rb b/lib/nylas.rb index 8756fdb5..736675aa 100644 --- a/lib/nylas.rb +++ b/lib/nylas.rb @@ -24,6 +24,7 @@ require_relative "nylas/resources/events" require_relative "nylas/resources/folders" require_relative "nylas/resources/grants" +require_relative "nylas/resources/lists" require_relative "nylas/resources/messages" require_relative "nylas/resources/notetakers" require_relative "nylas/resources/smart_compose" diff --git a/lib/nylas/client.rb b/lib/nylas/client.rb index 37449808..c9793d6c 100644 --- a/lib/nylas/client.rb +++ b/lib/nylas/client.rb @@ -8,6 +8,7 @@ require_relative "resources/webhooks" require_relative "resources/applications" require_relative "resources/folders" +require_relative "resources/lists" require_relative "resources/notetakers" require_relative "resources/scheduler" @@ -99,6 +100,13 @@ def grants Grants.new(self) end + # The list resources for your Nylas application. + # + # @return [Nylas::Lists] List resources for your Nylas application + def lists + Lists.new(self) + end + # The message resources for your Nylas application. # # @return [Nylas::Messages] Message resources for your Nylas application diff --git a/lib/nylas/handler/api_operations.rb b/lib/nylas/handler/api_operations.rb index b1d59560..732beda8 100644 --- a/lib/nylas/handler/api_operations.rb +++ b/lib/nylas/handler/api_operations.rb @@ -15,9 +15,10 @@ module Get # # @param path [String] Destination path for the call. # @param query_params [Hash, {}] Query params to pass to the call. + # @param headers [Hash, {}] Additional HTTP headers to include in the payload. # @return [Array([Hash, Array], String, Hash)] Nylas data object, API Request ID, and response headers. - def get(path:, query_params: {}) - response = get_raw(path: path, query_params: query_params) + def get(path:, query_params: {}, headers: {}) + response = get_raw(path: path, query_params: query_params, headers: headers) [response[:data], response[:request_id], response[:headers]] end @@ -26,10 +27,11 @@ def get(path:, query_params: {}) # # @param path [String] Destination path for the call. # @param query_params [Hash, {}] Query params to pass to the call. + # @param headers [Hash, {}] Additional HTTP headers to include in the payload. # @return [Array, String, String, Hash>] # Nylas data array, API Request ID, next cursor, and response headers.response headers. - def get_list(path:, query_params: {}) - response = get_raw(path: path, query_params: query_params) + def get_list(path:, query_params: {}, headers: {}) + response = get_raw(path: path, query_params: query_params, headers: headers) [response[:data], response[:request_id], response[:next_cursor], response[:headers]] end @@ -40,16 +42,20 @@ def get_list(path:, query_params: {}) # # @param path [String] Destination path for the call. # @param query_params [Hash, {}] Query params to pass to the call. + # @param headers [Hash, {}] Additional HTTP headers to include in the payload. # @return [Hash] The JSON response from the Nylas API. - def get_raw(path:, query_params: {}) - execute( + def get_raw(path:, query_params: {}, headers: {}) + request = { method: :get, path: path, query: query_params, payload: nil, api_key: api_key, timeout: timeout - ) + } + request[:headers] = headers unless headers.empty? + + execute(**request) end end diff --git a/lib/nylas/resources/domains.rb b/lib/nylas/resources/domains.rb index 0b6ace6c..7ac2f01c 100644 --- a/lib/nylas/resources/domains.rb +++ b/lib/nylas/resources/domains.rb @@ -5,7 +5,7 @@ module Nylas # Module representing the possible 'type' values in a domain verification attempt. - # @see https://developer.nylas.com/docs/api/v3/admin/#tag--Manage-Domains + # @see https://developer.nylas.com/docs/reference/api/manage-domains/ module DomainVerificationType OWNERSHIP = "ownership" MX = "mx" @@ -24,22 +24,37 @@ module DomainVerificationStatus end # Nylas Manage Domains API + # + # These endpoints require Nylas Service Account request signing. Pass headers + # containing `X-Nylas-Kid`, `X-Nylas-Timestamp`, `X-Nylas-Nonce`, and + # `X-Nylas-Signature` generated for the exact request being sent. class Domains < Resource include ApiOperations::Get include ApiOperations::Post include ApiOperations::Put include ApiOperations::Delete + REQUIRED_SERVICE_ACCOUNT_HEADERS = %w[ + X-Nylas-Kid + X-Nylas-Timestamp + X-Nylas-Nonce + X-Nylas-Signature + ].freeze + # Return all domains for the caller's organization. # # @param query_params [Hash, nil] Query params to pass to the request. # Supported keys: `domain` (filter by exact domain address), `region`, `limit`, `page_token`. + # @param headers [Hash] Nylas Service Account request signing headers. # @return [Array(Array(Hash), String, String, Hash)] # The list of domains, API Request ID, next cursor, and response headers. - def list(query_params: nil) + def list(headers:, query_params: nil) + validate_service_account_headers!(headers) + get_list( path: "#{api_uri}/v3/admin/domains", - query_params: query_params + query_params: query_params, + headers: headers ) end @@ -47,10 +62,14 @@ def list(query_params: nil) # # @param domain_id [String] The identifier of the domain to return. # Accepts either a UUID or a domain address (FQDN/email format). + # @param headers [Hash] Nylas Service Account request signing headers. # @return [Array(Hash, String, Hash)] The domain, API request ID, and response headers. - def find(domain_id:) + def find(domain_id:, headers:) + validate_service_account_headers!(headers) + get( - path: "#{api_uri}/v3/admin/domains/#{domain_id}" + path: "#{api_uri}/v3/admin/domains/#{domain_id}", + headers: headers ) end @@ -58,11 +77,15 @@ def find(domain_id:) # # @param request_body [Hash] The values to create the domain with. # Requires `name` and `domain_address`. + # @param headers [Hash] Nylas Service Account request signing headers. # @return [Array(Hash, String, Hash)] The created domain, API Request ID, and response headers. - def create(request_body:) + def create(request_body:, headers:) + validate_service_account_headers!(headers) + post( path: "#{api_uri}/v3/admin/domains", - request_body: request_body + request_body: request_body, + headers: headers ) end @@ -72,11 +95,15 @@ def create(request_body:) # Accepts either a UUID or a domain address (FQDN/email format). # @param request_body [Hash] The values to update the domain with. # The response echoes only the updated fields, not a full domain object. + # @param headers [Hash] Nylas Service Account request signing headers. # @return [Array(Hash, String)] The updated domain fields and API Request ID. - def update(domain_id:, request_body:) + def update(domain_id:, request_body:, headers:) + validate_service_account_headers!(headers) + put( path: "#{api_uri}/v3/admin/domains/#{domain_id}", - request_body: request_body + request_body: request_body, + headers: headers ) end @@ -84,10 +111,14 @@ def update(domain_id:, request_body:) # # @param domain_id [String] The identifier of the domain to delete. # Accepts either a UUID or a domain address (FQDN/email format). + # @param headers [Hash] Nylas Service Account request signing headers. # @return [Array(TrueClass, String)] True and the API Request ID for the delete operation. - def destroy(domain_id:) + def destroy(domain_id:, headers:) + validate_service_account_headers!(headers) + _, request_id = delete( - path: "#{api_uri}/v3/admin/domains/#{domain_id}" + path: "#{api_uri}/v3/admin/domains/#{domain_id}", + headers: headers ) [true, request_id] @@ -98,12 +129,16 @@ def destroy(domain_id:) # @param domain_id [String] The identifier of the domain. # Accepts either a UUID or a domain address (FQDN/email format). # @param request_body [Hash] The verification attempt values. Requires `type`. + # @param headers [Hash] Nylas Service Account request signing headers. # @return [Array(Hash, String, Hash)] # The domain verification result, API Request ID, and response headers. - def info(domain_id:, request_body:) + def info(domain_id:, request_body:, headers:) + validate_service_account_headers!(headers) + post( path: "#{api_uri}/v3/admin/domains/#{domain_id}/info", - request_body: request_body + request_body: request_body, + headers: headers ) end @@ -112,13 +147,31 @@ def info(domain_id:, request_body:) # @param domain_id [String] The identifier of the domain. # Accepts either a UUID or a domain address (FQDN/email format). # @param request_body [Hash] The verification attempt values. Requires `type`. + # @param headers [Hash] Nylas Service Account request signing headers. # @return [Array(Hash, String, Hash)] # The domain verification result, API Request ID, and response headers. - def verify(domain_id:, request_body:) + def verify(domain_id:, request_body:, headers:) + validate_service_account_headers!(headers) + post( path: "#{api_uri}/v3/admin/domains/#{domain_id}/verify", - request_body: request_body + request_body: request_body, + headers: headers ) end + + private + + def validate_service_account_headers!(headers) + header_values = headers || {} + missing_headers = REQUIRED_SERVICE_ACCOUNT_HEADERS.reject do |header| + header_values.key?(header) && !header_values[header].to_s.empty? + end + + return if missing_headers.empty? + + raise ArgumentError, + "Missing required service account authentication headers: #{missing_headers.join(', ')}" + end end end diff --git a/lib/nylas/resources/lists.rb b/lib/nylas/resources/lists.rb new file mode 100644 index 00000000..d89c45b5 --- /dev/null +++ b/lib/nylas/resources/lists.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +require_relative "resource" +require_relative "../handler/api_operations" + +module Nylas + # Module representing the possible 'type' values for a List. + module ListType + DOMAIN = "domain" + TLD = "tld" + ADDRESS = "address" + end + + # Nylas Lists API + # + # Lists are typed collections of domains, TLDs, or email addresses that can + # be referenced by Rules using the +in_list+ condition operator. + class Lists < Resource + include ApiOperations::Post + + # Create a list for the application. + # + # @param request_body [Hash] The public values to create the list with. + # Supported keys: +name+ (required, 1-256 chars), +type+ (required; one of + # +domain+, +tld+, or +address+), and +description+ (optional). The server + # assigns identifiers, item counts, timestamps, and application ownership. + # @return [Array(Hash, String, Hash)] The created list, API Request ID, and + # response headers. + def create(request_body:) + post( + path: "#{api_uri}/v3/lists", + request_body: request_body + ) + end + end +end diff --git a/spec/nylas/client_spec.rb b/spec/nylas/client_spec.rb index 882c0d1f..73eebd1e 100644 --- a/spec/nylas/client_spec.rb +++ b/spec/nylas/client_spec.rb @@ -47,6 +47,7 @@ expect(nylas.drafts).to be_a(Nylas::Drafts) expect(nylas.events).to be_a(Nylas::Events) expect(nylas.grants).to be_a(Nylas::Grants) + expect(nylas.lists).to be_a(Nylas::Lists) expect(nylas.folders).to be_a(Nylas::Folders) expect(nylas.messages).to be_a(Nylas::Messages) expect(nylas.threads).to be_a(Nylas::Threads) diff --git a/spec/nylas/resources/domains_spec.rb b/spec/nylas/resources/domains_spec.rb index 2225bcd0..386618fc 100644 --- a/spec/nylas/resources/domains_spec.rb +++ b/spec/nylas/resources/domains_spec.rb @@ -2,6 +2,14 @@ describe Nylas::Domains do let(:domains) { described_class.new(client) } + let(:signed_headers) do + { + "X-Nylas-Kid" => "service-account-123", + "X-Nylas-Timestamp" => "1742932766", + "X-Nylas-Nonce" => "nonce-123", + "X-Nylas-Signature" => "signature-123" + } + end let(:response) do [{ id: "f9d3c1b2-1a2b-4c3d-8e4f-1234567890ab", @@ -30,10 +38,10 @@ it "calls the get_list method with the correct parameters" do path = "#{api_uri}/v3/admin/domains" allow(domains).to receive(:get_list) - .with(path: path, query_params: nil) + .with(path: path, query_params: nil, headers: signed_headers) .and_return(list_response) - domains_response = domains.list + domains_response = domains.list(headers: signed_headers) expect(domains_response).to eq(list_response) end @@ -42,10 +50,10 @@ query_params = { domain: "mail.example.com", region: "us" } path = "#{api_uri}/v3/admin/domains" allow(domains).to receive(:get_list) - .with(path: path, query_params: query_params) + .with(path: path, query_params: query_params, headers: signed_headers) .and_return(list_response) - domains_response = domains.list(query_params: query_params) + domains_response = domains.list(query_params: query_params, headers: signed_headers) expect(domains_response).to eq(list_response) end @@ -56,10 +64,10 @@ domain_id = "f9d3c1b2-1a2b-4c3d-8e4f-1234567890ab" path = "#{api_uri}/v3/admin/domains/#{domain_id}" allow(domains).to receive(:get) - .with(path: path) + .with(path: path, headers: signed_headers) .and_return(response) - domain_response = domains.find(domain_id: domain_id) + domain_response = domains.find(domain_id: domain_id, headers: signed_headers) expect(domain_response).to eq(response) end @@ -68,10 +76,10 @@ domain_id = "mail.example.com" path = "#{api_uri}/v3/admin/domains/#{domain_id}" allow(domains).to receive(:get) - .with(path: path) + .with(path: path, headers: signed_headers) .and_return(response) - domain_response = domains.find(domain_id: domain_id) + domain_response = domains.find(domain_id: domain_id, headers: signed_headers) expect(domain_response).to eq(response) end @@ -85,10 +93,10 @@ } path = "#{api_uri}/v3/admin/domains" allow(domains).to receive(:post) - .with(path: path, request_body: request_body) + .with(path: path, request_body: request_body, headers: signed_headers) .and_return(response) - domain_response = domains.create(request_body: request_body) + domain_response = domains.create(request_body: request_body, headers: signed_headers) expect(domain_response).to eq(response) end @@ -100,10 +108,11 @@ request_body = { name: "Renamed domain" } path = "#{api_uri}/v3/admin/domains/#{domain_id}" allow(domains).to receive(:put) - .with(path: path, request_body: request_body) + .with(path: path, request_body: request_body, headers: signed_headers) .and_return([{ name: "Renamed domain", updated_at: 1234567890 }, "mock_request_id"]) - domain_response = domains.update(domain_id: domain_id, request_body: request_body) + domain_response = domains.update(domain_id: domain_id, request_body: request_body, + headers: signed_headers) expect(domain_response).to eq([{ name: "Renamed domain", updated_at: 1234567890 }, "mock_request_id"]) end @@ -114,10 +123,10 @@ domain_id = "f9d3c1b2-1a2b-4c3d-8e4f-1234567890ab" path = "#{api_uri}/v3/admin/domains/#{domain_id}" allow(domains).to receive(:delete) - .with(path: path) + .with(path: path, headers: signed_headers) .and_return([true, "mock_request_id"]) - domain_response = domains.destroy(domain_id: domain_id) + domain_response = domains.destroy(domain_id: domain_id, headers: signed_headers) expect(domain_response).to eq([true, "mock_request_id"]) end @@ -136,10 +145,11 @@ message: "Please configure the TXT record." }, "mock_request_id"] allow(domains).to receive(:post) - .with(path: path, request_body: request_body) + .with(path: path, request_body: request_body, headers: signed_headers) .and_return(result) - domain_response = domains.info(domain_id: domain_id, request_body: request_body) + domain_response = domains.info(domain_id: domain_id, request_body: request_body, + headers: signed_headers) expect(domain_response).to eq(result) end @@ -156,12 +166,22 @@ status: "done" }, "mock_request_id"] allow(domains).to receive(:post) - .with(path: path, request_body: request_body) + .with(path: path, request_body: request_body, headers: signed_headers) .and_return(result) - domain_response = domains.verify(domain_id: domain_id, request_body: request_body) + domain_response = domains.verify(domain_id: domain_id, request_body: request_body, + headers: signed_headers) expect(domain_response).to eq(result) end end + + describe "service account authentication" do + it "requires all service account request signing headers" do + unsigned_headers = signed_headers.reject { |key, _| key == "X-Nylas-Signature" } + + expect { domains.list(headers: unsigned_headers) } + .to raise_error(ArgumentError, /X-Nylas-Signature/) + end + end end diff --git a/spec/nylas/resources/lists_spec.rb b/spec/nylas/resources/lists_spec.rb new file mode 100644 index 00000000..e2822507 --- /dev/null +++ b/spec/nylas/resources/lists_spec.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +describe Nylas::Lists do + let(:lists) { described_class.new(client) } + let(:response) do + [{ + id: "list-123", + name: "Blocked domains", + description: "Domains we have identified as sending unwanted mail.", + type: "domain", + items_count: 0, + application_id: "app-123", + organization_id: "org-123", + created_at: 1_234_567_890, + updated_at: 1_234_567_890 + }, "mock_request_id", {}] + end + + describe Nylas::ListType do + it "defines the public list type values" do + expect(described_class::DOMAIN).to eq("domain") + expect(described_class::TLD).to eq("tld") + expect(described_class::ADDRESS).to eq("address") + end + end + + describe "#create" do + it "calls the post method with the correct path and public request body" do + request_body = { + name: "Blocked domains", + description: "Domains we have identified as sending unwanted mail.", + type: Nylas::ListType::DOMAIN + } + path = "#{api_uri}/v3/lists" + allow(lists).to receive(:post) + .with(path: path, request_body: request_body) + .and_return(response) + + lists_response = lists.create(request_body: request_body) + + expect(lists_response).to eq(response) + end + end +end From edf7ab38a940f1ed4a2a8adfdc46a0231238d43f Mon Sep 17 00:00:00 2001 From: Aaron de Mello Date: Fri, 12 Jun 2026 10:40:05 -0400 Subject: [PATCH 4/7] Add service account signing for domains --- CHANGELOG.md | 2 +- lib/nylas.rb | 1 + lib/nylas/handler/api_operations.rb | 24 ++- lib/nylas/handler/http_client.rb | 18 +- lib/nylas/handler/service_account_signer.rb | 112 +++++++++++ lib/nylas/resources/domains.rb | 175 +++++++++++++----- spec/nylas/handler/http_client_spec.rb | 22 +++ .../handler/service_account_signer_spec.rb | 90 +++++++++ spec/nylas/resources/domains_spec.rb | 54 ++++++ 9 files changed, 435 insertions(+), 63 deletions(-) create mode 100644 lib/nylas/handler/service_account_signer.rb create mode 100644 spec/nylas/handler/service_account_signer_spec.rb diff --git a/CHANGELOG.md b/CHANGELOG.md index 9fab52e3..eb2fd2c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ * Added Rules resource for managing inbox rules and listing rule evaluations * Added Lists resource support for creating application lists * Added Workspaces resource for managing workspaces, auto-grouping, manual assignment, `default`, `policy_id`, and `rule_ids` -* Added Domains resource for signed Service Account Manage Domains requests, including info and verify operations +* Added Domains resource and `ServiceAccountSigner` support for signed Service Account Manage Domains requests, including info and verify operations * Added Applications update support (PATCH /v3/applications) * Corrected RedirectUris update verb from PUT to PATCH * Fixed HTTParty content type issue when request body is nil - POST, PUT, PATCH, and DELETE now default to empty object to ensure Content-Type: application/json is sent (#536) diff --git a/lib/nylas.rb b/lib/nylas.rb index 736675aa..f85b1f64 100644 --- a/lib/nylas.rb +++ b/lib/nylas.rb @@ -12,6 +12,7 @@ require_relative "nylas/config" require_relative "nylas/handler/http_client" +require_relative "nylas/handler/service_account_signer" require_relative "nylas/resources/applications" require_relative "nylas/resources/attachments" diff --git a/lib/nylas/handler/api_operations.rb b/lib/nylas/handler/api_operations.rb index 732beda8..6ed7bcf0 100644 --- a/lib/nylas/handler/api_operations.rb +++ b/lib/nylas/handler/api_operations.rb @@ -71,8 +71,8 @@ module Post # @param request_body [Hash, nil] Request body to pass to the call. # @param headers [Hash, {}] Additional HTTP headers to include in the payload. # @return [Array(Hash, String, Hash)] Nylas data object, API Request ID, and response headers. - def post(path:, query_params: {}, request_body: nil, headers: {}) - response = execute( + def post(path:, query_params: {}, request_body: nil, headers: {}, serialized_json_body: nil) + request = { method: :post, path: path, query: query_params, @@ -80,7 +80,9 @@ def post(path:, query_params: {}, request_body: nil, headers: {}) headers: headers, api_key: api_key, timeout: timeout - ) + } + request[:serialized_json_body] = serialized_json_body unless serialized_json_body.nil? + response = execute(**request) [response[:data], response[:request_id], response[:headers]] end @@ -98,8 +100,8 @@ module Put # @param request_body [Hash, nil] Request body to pass to the call. # @param headers [Hash, {}] Additional HTTP headers to include in the payload. # @return Nylas data object and API Request ID. - def put(path:, query_params: {}, request_body: nil, headers: {}) - response = execute( + def put(path:, query_params: {}, request_body: nil, headers: {}, serialized_json_body: nil) + request = { method: :put, path: path, query: query_params, @@ -107,7 +109,9 @@ def put(path:, query_params: {}, request_body: nil, headers: {}) headers: headers, api_key: api_key, timeout: timeout - ) + } + request[:serialized_json_body] = serialized_json_body unless serialized_json_body.nil? + response = execute(**request) [response[:data], response[:request_id]] end @@ -125,8 +129,8 @@ module Patch # @param request_body [Hash, nil] Request body to pass to the call. # @param headers [Hash, {}] Additional HTTP headers to include in the payload. # @return Nylas data object and API Request ID. - def patch(path:, query_params: {}, request_body: nil, headers: {}) - response = execute( + def patch(path:, query_params: {}, request_body: nil, headers: {}, serialized_json_body: nil) + request = { method: :patch, path: path, query: query_params, @@ -134,7 +138,9 @@ def patch(path:, query_params: {}, request_body: nil, headers: {}) headers: headers, api_key: api_key, timeout: timeout - ) + } + request[:serialized_json_body] = serialized_json_body unless serialized_json_body.nil? + response = execute(**request) [response[:data], response[:request_id]] end diff --git a/lib/nylas/handler/http_client.rb b/lib/nylas/handler/http_client.rb index 8578dd48..1196dfba 100644 --- a/lib/nylas/handler/http_client.rb +++ b/lib/nylas/handler/http_client.rb @@ -30,11 +30,14 @@ module HttpClient # @param query [Hash, {}] Hash of names and values to include in the query section of the URI # fragment. # @param payload [Hash, nil] Body to send with the request. + # @param serialized_json_body [String, nil] Pre-serialized JSON body to send as-is. # @param api_key [Hash, nil] API key to send with the request. # @return [Object] Parsed JSON response from the API. - def execute(method:, path:, timeout:, headers: {}, query: {}, payload: nil, api_key: nil) + def execute(method:, path:, timeout:, headers: {}, query: {}, payload: nil, api_key: nil, + serialized_json_body: nil) request = build_request(method: method, path: path, headers: headers, - query: query, payload: payload, api_key: api_key, timeout: timeout) + query: query, payload: payload, api_key: api_key, timeout: timeout, + serialized_json_body: serialized_json_body) begin httparty_execute(**request) do |response, _request, result| content_type = nil @@ -91,12 +94,14 @@ def download_request(path:, timeout:, headers: {}, query: {}, api_key: nil, &blo # @param query [Hash, {}] Hash of names and values to include in the query section of the URI # fragment. # @param payload [Hash, nil] Body to send with the request. + # @param serialized_json_body [String, nil] Pre-serialized JSON body to send as-is. # @param timeout [Integer, nil] Timeout value to send with the request. # @param api_key [Hash, nil] API key to send with the request. # @return [Object] The request information after processing. This includes an updated payload # and headers. def build_request( - method:, path: nil, headers: {}, query: {}, payload: nil, timeout: nil, api_key: nil + method:, path: nil, headers: {}, query: {}, payload: nil, timeout: nil, api_key: nil, + serialized_json_body: nil ) url = build_url(path, query) resulting_headers = default_headers.merge(headers).merge(auth_header(api_key)) @@ -104,7 +109,10 @@ def build_request( # Check for multipart flag using both string and symbol keys for backwards compatibility is_multipart = !payload.nil? && (payload["multipart"] || payload[:multipart]) - if !payload.nil? && !is_multipart + if !serialized_json_body.nil? + payload = serialized_json_body + resulting_headers["Content-type"] = "application/json" + elsif !payload.nil? && !is_multipart normalize_json_encodings!(payload) payload = payload&.to_json resulting_headers["Content-type"] = "application/json" @@ -489,6 +497,8 @@ def build_query(uri, query) # Set the authorization header for an API query. def auth_header(api_key) + return {} if api_key.nil? + { "Authorization" => "Bearer #{api_key}" } end end diff --git a/lib/nylas/handler/service_account_signer.rb b/lib/nylas/handler/service_account_signer.rb new file mode 100644 index 00000000..f1ca1041 --- /dev/null +++ b/lib/nylas/handler/service_account_signer.rb @@ -0,0 +1,112 @@ +# frozen_string_literal: true + +require "base64" +require "json" +require "openssl" +require "securerandom" + +module Nylas + # Builds Nylas Service Account request signing headers for organization admin APIs. + # + # @see https://developer.nylas.com/docs/v3/auth/nylas-service-account/ + class ServiceAccountSigner + NONCE_ALPHABET = ("a".."z").to_a.concat(("A".."Z").to_a, ("0".."9").to_a).freeze + DEFAULT_NONCE_LENGTH = 20 + SIGNED_BODY_METHODS = %w[post put patch].freeze + + attr_reader :private_key_id + + # @param private_key_pem [String] RSA private key in PEM format. + # @param private_key_id [String] Value for the X-Nylas-Kid header. + def initialize(private_key_pem:, private_key_id:) + @private_key = self.class.load_rsa_private_key(private_key_pem) + @private_key_id = private_key_id + end + + # Returns deterministic JSON with keys sorted at every object level and no extra whitespace. + # + # @param data [Hash, Array, String, Numeric, true, false, nil] Data to serialize. + # @return [String] Canonical JSON string. + def self.canonical_json(data) + JSON.generate(canonicalize(data)) + end + + # Loads an RSA private key from a PEM string. + # + # @param private_key_pem [String] RSA private key in PEM format. + # @return [OpenSSL::PKey::RSA] + def self.load_rsa_private_key(private_key_pem) + key = OpenSSL::PKey::RSA.new(private_key_pem) + raise ArgumentError, "Private key must be RSA private key" unless key.private? + raise ArgumentError, "Private key must be at least 2048 bits" if key.n.num_bits < 2048 + + key + rescue OpenSSL::PKey::PKeyError + raise ArgumentError, "Private key must be RSA PEM" + end + + # Generates a cryptographically secure alphanumeric nonce. + # + # @param length [Integer] Length of the nonce to generate. + # @return [String] Generated nonce. + def self.generate_nonce(length = DEFAULT_NONCE_LENGTH) + Array.new(length) { NONCE_ALPHABET[SecureRandom.random_number(NONCE_ALPHABET.length)] }.join + end + + # Builds signed headers and, for JSON body methods, the exact canonical body to send. + # + # @param method [String, Symbol] HTTP method. + # @param path [String] Relative request path, for example "/v3/admin/domains". + # @param body [Hash, nil] Request body for POST/PUT/PATCH requests. + # @param timestamp [Integer, nil] Optional Unix timestamp in seconds, mainly for tests. + # @param nonce [String, nil] Optional nonce, mainly for tests. + # @return [Array(Hash, String)] Signed headers and optional serialized JSON body. + def build_headers(method:, path:, body: nil, timestamp: nil, nonce: nil) + timestamp ||= Time.now.to_i + nonce ||= self.class.generate_nonce + method_value = method.to_s.downcase + serialized_body = nil + + if SIGNED_BODY_METHODS.include?(method_value) && !body.nil? + serialized_body = self.class.canonical_json(body) + end + + envelope = { + method: method_value, + nonce: nonce, + path: path, + timestamp: timestamp + } + envelope[:payload] = serialized_body if serialized_body + + signature = @private_key.sign(OpenSSL::Digest.new("SHA256"), self.class.canonical_json(envelope)) + + [ + { + "X-Nylas-Kid" => private_key_id, + "X-Nylas-Nonce" => nonce, + "X-Nylas-Timestamp" => timestamp.to_s, + "X-Nylas-Signature" => Base64.strict_encode64(signature) + }, + serialized_body + ] + end + + class << self + private + + def canonicalize(value) + case value + when Hash + value.keys.sort_by(&:to_s).each_with_object({}) do |key, result| + result[key.to_s] = canonicalize(value[key]) + end + when Array + value.map { |item| canonicalize(item) } + else + value + end + end + end + end +end diff --git a/lib/nylas/resources/domains.rb b/lib/nylas/resources/domains.rb index 7ac2f01c..c88ff281 100644 --- a/lib/nylas/resources/domains.rb +++ b/lib/nylas/resources/domains.rb @@ -2,6 +2,7 @@ require_relative "resource" require_relative "../handler/api_operations" +require_relative "../handler/service_account_signer" module Nylas # Module representing the possible 'type' values in a domain verification attempt. @@ -40,21 +41,24 @@ class Domains < Resource X-Nylas-Nonce X-Nylas-Signature ].freeze + DOMAINS_PATH = "/v3/admin/domains" # Return all domains for the caller's organization. # # @param query_params [Hash, nil] Query params to pass to the request. # Supported keys: `domain` (filter by exact domain address), `region`, `limit`, `page_token`. - # @param headers [Hash] Nylas Service Account request signing headers. + # @param headers [Hash, nil] Nylas Service Account request signing headers. + # @param signer [ServiceAccountSigner, nil] Signer to generate Nylas Service Account headers. # @return [Array(Array(Hash), String, String, Hash)] # The list of domains, API Request ID, next cursor, and response headers. - def list(headers:, query_params: nil) - validate_service_account_headers!(headers) + def list(headers: nil, query_params: nil, signer: nil) + request_headers, = signed_request_headers(method: :get, relative_path: DOMAINS_PATH, + headers: headers, signer: signer) get_list( - path: "#{api_uri}/v3/admin/domains", + path: full_path(DOMAINS_PATH), query_params: query_params, - headers: headers + headers: request_headers ) end @@ -62,14 +66,17 @@ def list(headers:, query_params: nil) # # @param domain_id [String] The identifier of the domain to return. # Accepts either a UUID or a domain address (FQDN/email format). - # @param headers [Hash] Nylas Service Account request signing headers. + # @param headers [Hash, nil] Nylas Service Account request signing headers. + # @param signer [ServiceAccountSigner, nil] Signer to generate Nylas Service Account headers. # @return [Array(Hash, String, Hash)] The domain, API request ID, and response headers. - def find(domain_id:, headers:) - validate_service_account_headers!(headers) + def find(domain_id:, headers: nil, signer: nil) + relative_path = "#{DOMAINS_PATH}/#{domain_id}" + request_headers, = signed_request_headers(method: :get, relative_path: relative_path, + headers: headers, signer: signer) get( - path: "#{api_uri}/v3/admin/domains/#{domain_id}", - headers: headers + path: full_path(relative_path), + headers: request_headers ) end @@ -77,16 +84,25 @@ def find(domain_id:, headers:) # # @param request_body [Hash] The values to create the domain with. # Requires `name` and `domain_address`. - # @param headers [Hash] Nylas Service Account request signing headers. + # @param headers [Hash, nil] Nylas Service Account request signing headers. + # @param signer [ServiceAccountSigner, nil] Signer to generate Nylas Service Account headers. # @return [Array(Hash, String, Hash)] The created domain, API Request ID, and response headers. - def create(request_body:, headers:) - validate_service_account_headers!(headers) - - post( - path: "#{api_uri}/v3/admin/domains", - request_body: request_body, - headers: headers + def create(request_body:, headers: nil, signer: nil) + request_headers, serialized_body = signed_request_headers( + method: :post, + relative_path: DOMAINS_PATH, + body: request_body, + headers: headers, + signer: signer ) + + request = { + path: full_path(DOMAINS_PATH), + request_body: serialized_body.nil? ? request_body : nil, + headers: request_headers + } + request[:serialized_json_body] = serialized_body unless serialized_body.nil? + post(**request) end # Update a domain. @@ -95,30 +111,43 @@ def create(request_body:, headers:) # Accepts either a UUID or a domain address (FQDN/email format). # @param request_body [Hash] The values to update the domain with. # The response echoes only the updated fields, not a full domain object. - # @param headers [Hash] Nylas Service Account request signing headers. + # @param headers [Hash, nil] Nylas Service Account request signing headers. + # @param signer [ServiceAccountSigner, nil] Signer to generate Nylas Service Account headers. # @return [Array(Hash, String)] The updated domain fields and API Request ID. - def update(domain_id:, request_body:, headers:) - validate_service_account_headers!(headers) - - put( - path: "#{api_uri}/v3/admin/domains/#{domain_id}", - request_body: request_body, - headers: headers + def update(domain_id:, request_body:, headers: nil, signer: nil) + relative_path = "#{DOMAINS_PATH}/#{domain_id}" + request_headers, serialized_body = signed_request_headers( + method: :put, + relative_path: relative_path, + body: request_body, + headers: headers, + signer: signer ) + + request = { + path: full_path(relative_path), + request_body: serialized_body.nil? ? request_body : nil, + headers: request_headers + } + request[:serialized_json_body] = serialized_body unless serialized_body.nil? + put(**request) end # Delete a domain. # # @param domain_id [String] The identifier of the domain to delete. # Accepts either a UUID or a domain address (FQDN/email format). - # @param headers [Hash] Nylas Service Account request signing headers. + # @param headers [Hash, nil] Nylas Service Account request signing headers. + # @param signer [ServiceAccountSigner, nil] Signer to generate Nylas Service Account headers. # @return [Array(TrueClass, String)] True and the API Request ID for the delete operation. - def destroy(domain_id:, headers:) - validate_service_account_headers!(headers) + def destroy(domain_id:, headers: nil, signer: nil) + relative_path = "#{DOMAINS_PATH}/#{domain_id}" + request_headers, = signed_request_headers(method: :delete, relative_path: relative_path, + headers: headers, signer: signer) _, request_id = delete( - path: "#{api_uri}/v3/admin/domains/#{domain_id}", - headers: headers + path: full_path(relative_path), + headers: request_headers ) [true, request_id] @@ -129,17 +158,27 @@ def destroy(domain_id:, headers:) # @param domain_id [String] The identifier of the domain. # Accepts either a UUID or a domain address (FQDN/email format). # @param request_body [Hash] The verification attempt values. Requires `type`. - # @param headers [Hash] Nylas Service Account request signing headers. + # @param headers [Hash, nil] Nylas Service Account request signing headers. + # @param signer [ServiceAccountSigner, nil] Signer to generate Nylas Service Account headers. # @return [Array(Hash, String, Hash)] # The domain verification result, API Request ID, and response headers. - def info(domain_id:, request_body:, headers:) - validate_service_account_headers!(headers) - - post( - path: "#{api_uri}/v3/admin/domains/#{domain_id}/info", - request_body: request_body, - headers: headers + def info(domain_id:, request_body:, headers: nil, signer: nil) + relative_path = "#{DOMAINS_PATH}/#{domain_id}/info" + request_headers, serialized_body = signed_request_headers( + method: :post, + relative_path: relative_path, + body: request_body, + headers: headers, + signer: signer ) + + request = { + path: full_path(relative_path), + request_body: serialized_body.nil? ? request_body : nil, + headers: request_headers + } + request[:serialized_json_body] = serialized_body unless serialized_body.nil? + post(**request) end # Trigger a DNS verification check for a domain verification type. @@ -147,25 +186,63 @@ def info(domain_id:, request_body:, headers:) # @param domain_id [String] The identifier of the domain. # Accepts either a UUID or a domain address (FQDN/email format). # @param request_body [Hash] The verification attempt values. Requires `type`. - # @param headers [Hash] Nylas Service Account request signing headers. + # @param headers [Hash, nil] Nylas Service Account request signing headers. + # @param signer [ServiceAccountSigner, nil] Signer to generate Nylas Service Account headers. # @return [Array(Hash, String, Hash)] # The domain verification result, API Request ID, and response headers. - def verify(domain_id:, request_body:, headers:) - validate_service_account_headers!(headers) - - post( - path: "#{api_uri}/v3/admin/domains/#{domain_id}/verify", - request_body: request_body, - headers: headers + def verify(domain_id:, request_body:, headers: nil, signer: nil) + relative_path = "#{DOMAINS_PATH}/#{domain_id}/verify" + request_headers, serialized_body = signed_request_headers( + method: :post, + relative_path: relative_path, + body: request_body, + headers: headers, + signer: signer ) + + request = { + path: full_path(relative_path), + request_body: serialized_body.nil? ? request_body : nil, + headers: request_headers + } + request[:serialized_json_body] = serialized_body unless serialized_body.nil? + post(**request) end private + # Manage Domains uses Nylas Service Account signing headers instead of API-key bearer auth. + def api_key + nil + end + + def full_path(relative_path) + "#{api_uri}#{relative_path}" + end + + def signed_request_headers(method:, relative_path:, headers:, signer:, body: nil) + request_headers = headers.nil? ? {} : headers.dup + serialized_body = nil + if signer + signer_headers, serialized_body = signer.build_headers( + method: method, + path: relative_path, + body: body + ) + request_headers.merge!(signer_headers) + end + + validate_service_account_headers!(request_headers) + [request_headers, serialized_body] + end + def validate_service_account_headers!(headers) header_values = headers || {} - missing_headers = REQUIRED_SERVICE_ACCOUNT_HEADERS.reject do |header| - header_values.key?(header) && !header_values[header].to_s.empty? + normalized_headers = header_values.transform_keys do |key| + key.to_s.downcase + end + missing_headers = REQUIRED_SERVICE_ACCOUNT_HEADERS.select do |header| + normalized_headers[header.downcase].to_s.empty? end return if missing_headers.empty? diff --git a/spec/nylas/handler/http_client_spec.rb b/spec/nylas/handler/http_client_spec.rb index a5d79ff8..208cc9ad 100644 --- a/spec/nylas/handler/http_client_spec.rb +++ b/spec/nylas/handler/http_client_spec.rb @@ -75,6 +75,28 @@ class TestHttpClient ) end + it "does not add an authorization header when no API key is provided" do + request = http_client.send(:build_request, method: :get, path: "https://test.api.nylas.com/foo", + api_key: nil) + + expect(request[:headers]).to eq( + "User-Agent" => "Nylas Ruby SDK 1.0.0 - 5.0.0", + "X-Nylas-API-Wrapper" => "ruby" + ) + end + + it "sends pre-serialized JSON bodies without re-serializing them" do + request = http_client.send(:build_request, method: :post, path: "https://test.api.nylas.com/foo", + api_key: "fake-key", + serialized_json_body: '{"a":1,"b":2}') + + expect(request[:payload]).to eq('{"a":1,"b":2}') + expect(request[:headers]).to include( + "Authorization" => "Bearer fake-key", + "Content-type" => "application/json" + ) + end + it "returns the correct request with custom headers" do extra_headers = { "X-Custom-Header" => "custom-value", diff --git a/spec/nylas/handler/service_account_signer_spec.rb b/spec/nylas/handler/service_account_signer_spec.rb new file mode 100644 index 00000000..eda6175d --- /dev/null +++ b/spec/nylas/handler/service_account_signer_spec.rb @@ -0,0 +1,90 @@ +# frozen_string_literal: true + +require "openssl" + +describe Nylas::ServiceAccountSigner do + let(:private_key) { OpenSSL::PKey::RSA.generate(2048) } + let(:private_key_pem) { private_key.to_pem } + + describe ".canonical_json" do + it "sorts keys recursively and omits extra whitespace" do + payload = { + z: [{ b: 1, a: 2 }], + y: { b: true, a: "value" } + } + + expect(described_class.canonical_json(payload)) + .to eq('{"y":{"a":"value","b":true},"z":[{"a":2,"b":1}]}') + end + end + + describe ".generate_nonce" do + it "generates a secure alphanumeric nonce with the requested length" do + nonce = described_class.generate_nonce(24) + + expect(nonce.length).to eq(24) + expect(nonce).to match(/\A[A-Za-z0-9]+\z/) + end + end + + describe ".load_rsa_private_key" do + it "rejects public keys" do + expect { described_class.load_rsa_private_key(private_key.public_key.to_pem) } + .to raise_error(ArgumentError, /private key/) + end + end + + describe "#build_headers" do + it "builds deterministic signed headers and canonical body for fixed inputs" do + signer = described_class.new(private_key_pem: private_key_pem, private_key_id: "kid-123") + body = { name: "My domain", domain_address: "mail.example.com" } + + headers, serialized_body = signer.build_headers( + method: :post, + path: "/v3/admin/domains", + body: body, + timestamp: 1_700_000_000, + nonce: "nonce123456789012345" + ) + + expected_body = '{"domain_address":"mail.example.com","name":"My domain"}' + expected_envelope = described_class.canonical_json( + method: "post", + nonce: "nonce123456789012345", + path: "/v3/admin/domains", + payload: expected_body, + timestamp: 1_700_000_000 + ) + expected_signature = Base64.strict_encode64( + private_key.sign(OpenSSL::Digest.new("SHA256"), expected_envelope) + ) + + expect(serialized_body).to eq(expected_body) + expect(headers).to eq( + "X-Nylas-Kid" => "kid-123", + "X-Nylas-Nonce" => "nonce123456789012345", + "X-Nylas-Timestamp" => "1700000000", + "X-Nylas-Signature" => expected_signature + ) + expect(private_key.public_key.verify( + OpenSSL::Digest.new("SHA256"), + Base64.decode64(headers["X-Nylas-Signature"]), + expected_envelope + )).to be(true) + end + + it "omits serialized body for GET requests" do + signer = described_class.new(private_key_pem: private_key_pem, private_key_id: "kid-123") + + headers, serialized_body = signer.build_headers( + method: "GET", + path: "/v3/admin/domains", + timestamp: 1, + nonce: "n" * 20 + ) + + expect(serialized_body).to be_nil + expect(headers["X-Nylas-Signature"]).not_to be_empty + end + end +end diff --git a/spec/nylas/resources/domains_spec.rb b/spec/nylas/resources/domains_spec.rb index 386618fc..2b846bf4 100644 --- a/spec/nylas/resources/domains_spec.rb +++ b/spec/nylas/resources/domains_spec.rb @@ -1,5 +1,7 @@ # frozen_string_literal: true +require "webmock/rspec" + describe Nylas::Domains do let(:domains) { described_class.new(client) } let(:signed_headers) do @@ -183,5 +185,57 @@ expect { domains.list(headers: unsigned_headers) } .to raise_error(ArgumentError, /X-Nylas-Signature/) end + + it "uses a signer to generate headers for list requests" do + signer = instance_double(Nylas::ServiceAccountSigner) + allow(signer).to receive(:build_headers) + .with(method: :get, path: "/v3/admin/domains", body: nil) + .and_return([signed_headers, nil]) + path = "#{api_uri}/v3/admin/domains" + allow(domains).to receive(:get_list) + .with(path: path, query_params: nil, headers: signed_headers) + .and_return([[response[0]], response[1], "mock_next_cursor"]) + + domains_response = domains.list(signer: signer) + + expect(domains_response).to eq([[response[0]], response[1], "mock_next_cursor"]) + end + + it "uses a signer to send canonical JSON and no bearer auth for create requests" do + private_key = OpenSSL::PKey::RSA.generate(2048) + signer = Nylas::ServiceAccountSigner.new(private_key_pem: private_key.to_pem, + private_key_id: "kid-123") + allow(Time).to receive(:now).and_return(Time.at(1_700_000_000)) + allow(Nylas::ServiceAccountSigner).to receive(:generate_nonce) + .and_return("nonce123456789012345") + request_body = { + name: "Marketing domain", + domain_address: "mail.example.com" + } + canonical_body = '{"domain_address":"mail.example.com","name":"Marketing domain"}' + captured_request = nil + request_stub = stub_request(:post, "#{api_uri}/v3/admin/domains") + .with { |request| captured_request = request } + .to_return( + status: 200, + body: { + data: response[0], + request_id: "mock_request_id" + }.to_json, + headers: { "Content-Type" => "application/json" } + ) + + domains.create(request_body: request_body, signer: signer) + + expect(request_stub).to have_been_requested + expect(captured_request.body).to eq(canonical_body) + expect(captured_request.headers).to include( + "X-Nylas-Kid" => "kid-123", + "X-Nylas-Nonce" => "nonce123456789012345", + "X-Nylas-Timestamp" => "1700000000" + ) + expect(captured_request.headers["X-Nylas-Signature"]).not_to be_empty + expect(captured_request.headers).not_to include("Authorization") + end end end From 28d0bf7431205378ad29e0c56be3180328077aee Mon Sep 17 00:00:00 2001 From: Aaron de Mello Date: Fri, 12 Jun 2026 11:01:03 -0400 Subject: [PATCH 5/7] Harden domains service account requests --- CHANGELOG.md | 2 +- lib/nylas/resources/domains.rb | 17 +++++++++++------ spec/nylas/resources/domains_spec.rb | 20 ++++++++++++++------ 3 files changed, 26 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index eb2fd2c8..4096cf1b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ * Added Rules resource for managing inbox rules and listing rule evaluations * Added Lists resource support for creating application lists * Added Workspaces resource for managing workspaces, auto-grouping, manual assignment, `default`, `policy_id`, and `rule_ids` -* Added Domains resource and `ServiceAccountSigner` support for signed Service Account Manage Domains requests, including info and verify operations +* Added Domains resource and `ServiceAccountSigner` support for signed Service Account Manage Domains requests, including canonical signed bodies, encoded domain paths, info, and verify operations * Added Applications update support (PATCH /v3/applications) * Corrected RedirectUris update verb from PUT to PATCH * Fixed HTTParty content type issue when request body is nil - POST, PUT, PATCH, and DELETE now default to empty object to ensure Content-Type: application/json is sent (#536) diff --git a/lib/nylas/resources/domains.rb b/lib/nylas/resources/domains.rb index c88ff281..b1f67d6b 100644 --- a/lib/nylas/resources/domains.rb +++ b/lib/nylas/resources/domains.rb @@ -3,6 +3,7 @@ require_relative "resource" require_relative "../handler/api_operations" require_relative "../handler/service_account_signer" +require "uri" module Nylas # Module representing the possible 'type' values in a domain verification attempt. @@ -70,7 +71,7 @@ def list(headers: nil, query_params: nil, signer: nil) # @param signer [ServiceAccountSigner, nil] Signer to generate Nylas Service Account headers. # @return [Array(Hash, String, Hash)] The domain, API request ID, and response headers. def find(domain_id:, headers: nil, signer: nil) - relative_path = "#{DOMAINS_PATH}/#{domain_id}" + relative_path = "#{DOMAINS_PATH}/#{encoded_domain_id(domain_id)}" request_headers, = signed_request_headers(method: :get, relative_path: relative_path, headers: headers, signer: signer) @@ -115,7 +116,7 @@ def create(request_body:, headers: nil, signer: nil) # @param signer [ServiceAccountSigner, nil] Signer to generate Nylas Service Account headers. # @return [Array(Hash, String)] The updated domain fields and API Request ID. def update(domain_id:, request_body:, headers: nil, signer: nil) - relative_path = "#{DOMAINS_PATH}/#{domain_id}" + relative_path = "#{DOMAINS_PATH}/#{encoded_domain_id(domain_id)}" request_headers, serialized_body = signed_request_headers( method: :put, relative_path: relative_path, @@ -141,7 +142,7 @@ def update(domain_id:, request_body:, headers: nil, signer: nil) # @param signer [ServiceAccountSigner, nil] Signer to generate Nylas Service Account headers. # @return [Array(TrueClass, String)] True and the API Request ID for the delete operation. def destroy(domain_id:, headers: nil, signer: nil) - relative_path = "#{DOMAINS_PATH}/#{domain_id}" + relative_path = "#{DOMAINS_PATH}/#{encoded_domain_id(domain_id)}" request_headers, = signed_request_headers(method: :delete, relative_path: relative_path, headers: headers, signer: signer) @@ -163,7 +164,7 @@ def destroy(domain_id:, headers: nil, signer: nil) # @return [Array(Hash, String, Hash)] # The domain verification result, API Request ID, and response headers. def info(domain_id:, request_body:, headers: nil, signer: nil) - relative_path = "#{DOMAINS_PATH}/#{domain_id}/info" + relative_path = "#{DOMAINS_PATH}/#{encoded_domain_id(domain_id)}/info" request_headers, serialized_body = signed_request_headers( method: :post, relative_path: relative_path, @@ -191,7 +192,7 @@ def info(domain_id:, request_body:, headers: nil, signer: nil) # @return [Array(Hash, String, Hash)] # The domain verification result, API Request ID, and response headers. def verify(domain_id:, request_body:, headers: nil, signer: nil) - relative_path = "#{DOMAINS_PATH}/#{domain_id}/verify" + relative_path = "#{DOMAINS_PATH}/#{encoded_domain_id(domain_id)}/verify" request_headers, serialized_body = signed_request_headers( method: :post, relative_path: relative_path, @@ -220,9 +221,13 @@ def full_path(relative_path) "#{api_uri}#{relative_path}" end + def encoded_domain_id(domain_id) + URI.encode_www_form_component(domain_id) + end + def signed_request_headers(method:, relative_path:, headers:, signer:, body: nil) request_headers = headers.nil? ? {} : headers.dup - serialized_body = nil + serialized_body = body.nil? ? nil : Nylas::ServiceAccountSigner.canonical_json(body) if signer signer_headers, serialized_body = signer.build_headers( method: method, diff --git a/spec/nylas/resources/domains_spec.rb b/spec/nylas/resources/domains_spec.rb index 2b846bf4..5a095206 100644 --- a/spec/nylas/resources/domains_spec.rb +++ b/spec/nylas/resources/domains_spec.rb @@ -63,8 +63,8 @@ describe "#find" do it "calls the get method with the correct parameters" do - domain_id = "f9d3c1b2-1a2b-4c3d-8e4f-1234567890ab" - path = "#{api_uri}/v3/admin/domains/#{domain_id}" + domain_id = "domain/with/slash" + path = "#{api_uri}/v3/admin/domains/domain%2Fwith%2Fslash" allow(domains).to receive(:get) .with(path: path, headers: signed_headers) .and_return(response) @@ -95,7 +95,10 @@ } path = "#{api_uri}/v3/admin/domains" allow(domains).to receive(:post) - .with(path: path, request_body: request_body, headers: signed_headers) + .with(path: path, + request_body: nil, + serialized_json_body: '{"domain_address":"mail.example.com","name":"Marketing domain"}', + headers: signed_headers) .and_return(response) domain_response = domains.create(request_body: request_body, headers: signed_headers) @@ -110,7 +113,10 @@ request_body = { name: "Renamed domain" } path = "#{api_uri}/v3/admin/domains/#{domain_id}" allow(domains).to receive(:put) - .with(path: path, request_body: request_body, headers: signed_headers) + .with(path: path, + request_body: nil, + serialized_json_body: '{"name":"Renamed domain"}', + headers: signed_headers) .and_return([{ name: "Renamed domain", updated_at: 1234567890 }, "mock_request_id"]) domain_response = domains.update(domain_id: domain_id, request_body: request_body, @@ -147,7 +153,8 @@ message: "Please configure the TXT record." }, "mock_request_id"] allow(domains).to receive(:post) - .with(path: path, request_body: request_body, headers: signed_headers) + .with(path: path, request_body: nil, serialized_json_body: '{"type":"ownership"}', + headers: signed_headers) .and_return(result) domain_response = domains.info(domain_id: domain_id, request_body: request_body, @@ -168,7 +175,8 @@ status: "done" }, "mock_request_id"] allow(domains).to receive(:post) - .with(path: path, request_body: request_body, headers: signed_headers) + .with(path: path, request_body: nil, serialized_json_body: '{"type":"dkim"}', + headers: signed_headers) .and_return(result) domain_response = domains.verify(domain_id: domain_id, request_body: request_body, From e215124ccc487393886cd751023f6dad6ce88c6e Mon Sep 17 00:00:00 2001 From: Aaron de Mello Date: Fri, 12 Jun 2026 11:48:45 -0400 Subject: [PATCH 6/7] Split domain verification request types --- lib/nylas/resources/domains.rb | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/lib/nylas/resources/domains.rb b/lib/nylas/resources/domains.rb index b1f67d6b..f51ac6cb 100644 --- a/lib/nylas/resources/domains.rb +++ b/lib/nylas/resources/domains.rb @@ -6,14 +6,24 @@ require "uri" module Nylas - # Module representing the possible 'type' values in a domain verification attempt. + # Module representing the possible 'type' values in a domain verification request. # @see https://developer.nylas.com/docs/reference/api/manage-domains/ - module DomainVerificationType + module DomainVerificationRequestType OWNERSHIP = "ownership" MX = "mx" SPF = "spf" DKIM = "dkim" FEEDBACK = "feedback" + end + + # Module representing the possible 'type' values in a domain verification result. + # @see https://developer.nylas.com/docs/reference/api/manage-domains/ + module DomainVerificationType + OWNERSHIP = DomainVerificationRequestType::OWNERSHIP + MX = DomainVerificationRequestType::MX + SPF = DomainVerificationRequestType::SPF + DKIM = DomainVerificationRequestType::DKIM + FEEDBACK = DomainVerificationRequestType::FEEDBACK DMARC = "dmarc" ARC = "arc" end From 1c9a86915074df85e57c8f9f6cc0812b6e5eebcc Mon Sep 17 00:00:00 2001 From: Aaron de Mello Date: Fri, 12 Jun 2026 12:04:17 -0400 Subject: [PATCH 7/7] Remove unsupported domain list filters --- lib/nylas/resources/domains.rb | 2 +- spec/nylas/resources/domains_spec.rb | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/nylas/resources/domains.rb b/lib/nylas/resources/domains.rb index f51ac6cb..2dfa87a0 100644 --- a/lib/nylas/resources/domains.rb +++ b/lib/nylas/resources/domains.rb @@ -57,7 +57,7 @@ class Domains < Resource # Return all domains for the caller's organization. # # @param query_params [Hash, nil] Query params to pass to the request. - # Supported keys: `domain` (filter by exact domain address), `region`, `limit`, `page_token`. + # Supported keys: `limit`, `page_token`. # @param headers [Hash, nil] Nylas Service Account request signing headers. # @param signer [ServiceAccountSigner, nil] Signer to generate Nylas Service Account headers. # @return [Array(Array(Hash), String, String, Hash)] diff --git a/spec/nylas/resources/domains_spec.rb b/spec/nylas/resources/domains_spec.rb index 5a095206..73209649 100644 --- a/spec/nylas/resources/domains_spec.rb +++ b/spec/nylas/resources/domains_spec.rb @@ -49,7 +49,7 @@ end it "calls the get_list method with the correct parameters and query params" do - query_params = { domain: "mail.example.com", region: "us" } + query_params = { limit: 10, page_token: "cursor-123" } path = "#{api_uri}/v3/admin/domains" allow(domains).to receive(:get_list) .with(path: path, query_params: query_params, headers: signed_headers)