From 0c2db29ed7b6092f6cd5f5193196e4fe602792ac Mon Sep 17 00:00:00 2001 From: Marcin Klocek Date: Fri, 13 Mar 2026 14:07:52 +0000 Subject: [PATCH] Add support for Email Logs API --- README.md | 1 + examples/email_logs_api.rb | 49 ++++++++ lib/mailtrap.rb | 1 + lib/mailtrap/email_log_event.rb | 18 +++ lib/mailtrap/email_log_event_details.rb | 58 +++++++++ lib/mailtrap/email_log_message.rb | 43 +++++++ lib/mailtrap/email_logs_api.rb | 112 +++++++++++++++++ lib/mailtrap/email_logs_list_response.rb | 15 +++ ...essage_with_events_and_raw_message_url.yml | 77 ++++++++++++ .../when_message_not_found/raises_error.yml | 71 +++++++++++ ...to_EmailLogsListResponse_with_messages.yml | 75 +++++++++++ ...to_EmailLogsListResponse_with_messages.yml | 73 +++++++++++ spec/mailtrap/email_log_event_details_spec.rb | 51 ++++++++ spec/mailtrap/email_log_event_spec.rb | 19 +++ spec/mailtrap/email_log_message_spec.rb | 31 +++++ spec/mailtrap/email_logs_api_spec.rb | 118 ++++++++++++++++++ 16 files changed, 812 insertions(+) create mode 100644 examples/email_logs_api.rb create mode 100644 lib/mailtrap/email_log_event.rb create mode 100644 lib/mailtrap/email_log_event_details.rb create mode 100644 lib/mailtrap/email_log_message.rb create mode 100644 lib/mailtrap/email_logs_api.rb create mode 100644 lib/mailtrap/email_logs_list_response.rb create mode 100644 spec/fixtures/vcr_cassettes/Mailtrap_EmailLogsAPI/_get/maps_response_data_to_EmailLogMessage_with_events_and_raw_message_url.yml create mode 100644 spec/fixtures/vcr_cassettes/Mailtrap_EmailLogsAPI/_get/when_message_not_found/raises_error.yml create mode 100644 spec/fixtures/vcr_cassettes/Mailtrap_EmailLogsAPI/_list/maps_response_data_to_EmailLogsListResponse_with_messages.yml create mode 100644 spec/fixtures/vcr_cassettes/Mailtrap_EmailLogsAPI/_list/with_filters_and_search_after/sends_correct_query_params_and_maps_response_data_to_EmailLogsListResponse_with_messages.yml create mode 100644 spec/mailtrap/email_log_event_details_spec.rb create mode 100644 spec/mailtrap/email_log_event_spec.rb create mode 100644 spec/mailtrap/email_log_message_spec.rb create mode 100644 spec/mailtrap/email_logs_api_spec.rb diff --git a/README.md b/README.md index 040004a..383f1da 100644 --- a/README.md +++ b/README.md @@ -178,6 +178,7 @@ Email API: - Batch Sending – [`batch.rb`](examples/batch.rb) - Sending Domains API – [`sending_domains_api.rb`](examples/sending_domains_api.rb) - Sending Stats API – [`stats_api.rb`](examples/stats_api.rb) +- Email Logs API – [`email_logs_api.rb`](examples/email_logs_api.rb) Email Sandbox (Testing): diff --git a/examples/email_logs_api.rb b/examples/email_logs_api.rb new file mode 100644 index 0000000..4bd2688 --- /dev/null +++ b/examples/email_logs_api.rb @@ -0,0 +1,49 @@ +require 'mailtrap' + +account_id = 3229 +client = Mailtrap::Client.new(api_key: 'your-api-key') +email_logs = Mailtrap::EmailLogsAPI.new(account_id, client) + +# Set your API credentials as environment variables +# export MAILTRAP_API_KEY='your-api-key' +# export MAILTRAP_ACCOUNT_ID=your-account-id +# +# email_logs = Mailtrap::EmailLogsAPI.new + +# List email logs (first page) +response = email_logs.list +# => #, ...], +# total_count=150, +# next_page_cursor="b2c3d4e5-f6a7-8901-bcde-f12345678901"> + +response.messages.each { |m| puts "#{m.message_id} #{m.status} #{m.subject}" } + +# List with filters (date range: last 2 days, recipient, status) +sent_after = (Time.now.utc - (2 * 24 * 3600)).iso8601 +sent_before = Time.now.utc.iso8601 +response = email_logs.list( + filters: { + sent_after:, + sent_before:, + subject: { operator: 'not_empty' }, + to: { operator: 'ci_equal', value: 'recipient@example.com' }, + category: { operator: 'equal', value: ['Welcome Email', 'Transactional Email'] } + } +) + +# List next page using cursor from previous response +response = email_logs.list(search_after: response.next_page_cursor) if response.next_page_cursor + +# Get a single message by ID (includes events and raw_message_url) +message_id = response.messages.first&.message_id +if message_id + message = email_logs.get(message_id) + # => #, ...]> + + puts message.raw_message_url + message.events&.each { |e| puts "#{e.event_type} at #{e.created_at}" } +end diff --git a/lib/mailtrap.rb b/lib/mailtrap.rb index f6f5ea9..dd07a7b 100644 --- a/lib/mailtrap.rb +++ b/lib/mailtrap.rb @@ -14,6 +14,7 @@ require_relative 'mailtrap/contact_imports_api' require_relative 'mailtrap/suppressions_api' require_relative 'mailtrap/sending_domains_api' +require_relative 'mailtrap/email_logs_api' require_relative 'mailtrap/projects_api' require_relative 'mailtrap/inboxes_api' require_relative 'mailtrap/sandbox_messages_api' diff --git a/lib/mailtrap/email_log_event.rb b/lib/mailtrap/email_log_event.rb new file mode 100644 index 0000000..e337646 --- /dev/null +++ b/lib/mailtrap/email_log_event.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module Mailtrap + # Data Transfer Object for an email log event (delivery, open, click, bounce, etc.) + # @see https://docs.mailtrap.io/developers/email-sending/email-logs + # @attr_reader event_type [String] One of: delivery, open, click, soft_bounce, bounce, spam, unsubscribe, suspension, + # reject + # @attr_reader created_at [String] ISO 8601 timestamp + # @attr_reader details [EmailLogEventDetails::Delivery, EmailLogEventDetails::Open, EmailLogEventDetails::Click, + # EmailLogEventDetails::Bounce, EmailLogEventDetails::Spam, EmailLogEventDetails::Unsubscribe, + # EmailLogEventDetails::Reject] Type-specific event details + EmailLogEvent = Struct.new( + :event_type, + :created_at, + :details, + keyword_init: true + ) +end diff --git a/lib/mailtrap/email_log_event_details.rb b/lib/mailtrap/email_log_event_details.rb new file mode 100644 index 0000000..27109ae --- /dev/null +++ b/lib/mailtrap/email_log_event_details.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +module Mailtrap + # Type-specific event detail structs for EmailLogEvent. Use event_type to determine which details schema applies. + # @see https://docs.mailtrap.io/developers/email-sending/email-logs + module EmailLogEventDetails + # For event_type = delivery + Delivery = Struct.new(:sending_ip, :recipient_mx, :email_service_provider, keyword_init: true) + + # For event_type = open + Open = Struct.new(:web_ip_address, keyword_init: true) + + # For event_type = click + Click = Struct.new(:click_url, :web_ip_address, keyword_init: true) + + # For event_type = soft_bounce or bounce + Bounce = Struct.new( + :sending_ip, + :recipient_mx, + :email_service_provider, + :email_service_provider_status, + :email_service_provider_response, + :bounce_category, + keyword_init: true + ) + + # For event_type = spam + Spam = Struct.new(:spam_feedback_type, keyword_init: true) + + # For event_type = unsubscribe + Unsubscribe = Struct.new(:web_ip_address, keyword_init: true) + + # For event_type = suspension or reject + Reject = Struct.new(:reject_reason, keyword_init: true) + + DETAIL_STRUCTS = { + 'delivery' => Delivery, + 'open' => Open, + 'click' => Click, + 'soft_bounce' => Bounce, + 'bounce' => Bounce, + 'spam' => Spam, + 'unsubscribe' => Unsubscribe, + 'suspension' => Reject, + 'reject' => Reject + }.freeze + + # Builds the appropriate detail struct from API response. + # @param event_type [String] Known event type (delivery, open, click, etc.) + # @param hash [Hash] Symbol-keyed details from parsed JSON + # @return [Delivery, Open, Click, Bounce, Spam, Unsubscribe, Reject] + def self.build(event_type, hash) + struct_class = DETAIL_STRUCTS[event_type.to_s] + attrs = hash.slice(*struct_class.members) + struct_class.new(**attrs) + end + end +end diff --git a/lib/mailtrap/email_log_message.rb b/lib/mailtrap/email_log_message.rb new file mode 100644 index 0000000..803ccc4 --- /dev/null +++ b/lib/mailtrap/email_log_message.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +module Mailtrap + # Data Transfer Object for an email log message (summary in list, full details when fetched by ID) + # @see https://docs.mailtrap.io/developers/email-sending/email-logs + # @attr_reader message_id [String] Message UUID + # @attr_reader status [String] delivered, not_delivered, enqueued, opted_out + # @attr_reader subject [String, nil] Email subject + # @attr_reader from [String] Sender address + # @attr_reader to [String] Recipient address + # @attr_reader sent_at [String] ISO 8601 timestamp + # @attr_reader client_ip [String, nil] Client IP that sent the email + # @attr_reader category [String, nil] Message category + # @attr_reader custom_variables [Hash] Custom variables + # @attr_reader sending_stream [String] transactional or bulk + # @attr_reader sending_domain_id [Integer] Sending domain ID + # @attr_reader template_id [Integer, nil] Template ID if sent from template + # @attr_reader template_variables [Hash] Template variables + # @attr_reader opens_count [Integer] Number of opens + # @attr_reader clicks_count [Integer] Number of clicks + # @attr_reader raw_message_url [String, nil] Signed URL to download raw .eml (only when fetched by ID) + # @attr_reader events [Array, nil] Event list (only when fetched by ID) + EmailLogMessage = Struct.new( + :message_id, + :status, + :subject, + :from, + :to, + :sent_at, + :client_ip, + :category, + :custom_variables, + :sending_stream, + :sending_domain_id, + :template_id, + :template_variables, + :opens_count, + :clicks_count, + :raw_message_url, + :events, + keyword_init: true + ) +end diff --git a/lib/mailtrap/email_logs_api.rb b/lib/mailtrap/email_logs_api.rb new file mode 100644 index 0000000..ad76fe9 --- /dev/null +++ b/lib/mailtrap/email_logs_api.rb @@ -0,0 +1,112 @@ +# frozen_string_literal: true + +require_relative 'base_api' +require_relative 'email_log_message' +require_relative 'email_log_event' +require_relative 'email_log_event_details' +require_relative 'email_logs_list_response' + +module Mailtrap + class EmailLogsAPI + include BaseAPI + + self.response_class = EmailLogMessage + + # Lists email logs with optional filters and cursor-based pagination. + # + # @param filters [Hash, nil] Optional filters. Top-level date keys use string values (ISO 8601); + # other keys use +{ operator:, value: }+. +value+ can be a single value or an Array for + # operators that accept multiple values (e.g. +equal+, +not_equal+, +ci_equal+, +ci_not_equal+). + # Examples: + # +{ sent_after: "2025-01-01T00:00:00Z", sent_before: "2025-01-31T23:59:59Z" }+ + # +{ to: { operator: "ci_equal", value: "recipient@example.com" } }+ + # +{ category: { operator: "equal", value: ["Welcome Email", "Transactional Email"] } }+ + # @param search_after [String, nil] Message UUID cursor for the next page (from previous +next_page_cursor+) + # @return [EmailLogsListResponse] messages, total_count, and next_page_cursor + # @!macro api_errors + def list(filters: nil, search_after: nil) + query_params = build_list_query_params(filters:, search_after:) + + response = client.get(base_path, query_params) + + build_list_response(response) + end + + # Fetches a single email log message by ID. + # + # @param sending_message_id [String] Message UUID + # @return [EmailLogMessage] Message with events and raw_message_url when available + # @!macro api_errors + def get(sending_message_id) + response = client.get("#{base_path}/#{sending_message_id}") + + handle_message_response(response) + end + + private + + def base_path + "/api/accounts/#{account_id}/email_logs" + end + + def build_list_query_params(filters:, search_after:) + {}.tap do |params| + params[:search_after] = search_after if search_after + params.merge!(flatten_filters(filters)) + end + end + + # Flattens a filters Hash into query param keys expected by the API (deepObject style). + # Scalar values => filters[key]; Hashes with :operator/:value => filters[key][operator], filters[key][value]. + # When :value is an Array, the key is repeated (e.g. filters[category][value]=A&filters[category][value]=B) + # for operators that accept multiple values (e.g. equal, not_equal, ci_equal, ci_not_equal). + def flatten_filters(filters) + return {} if filters.nil? || filters.empty? + + filters.each_with_object({}) do |(key, value), result| + flatten_filter_pair(key.to_s, value, result) + end + end + + def flatten_filter_pair(key_s, value, result) + if value.is_a?(Hash) + value.each { |k, v| result["filters[#{key_s}][#{k}]"] = filter_param_value(v) unless v.nil? } + else + result["filters[#{key_s}]"] = value.to_s + end + end + + def filter_param_value(value) + value.is_a?(Array) ? value : value.to_s + end + + def build_list_response(response) + EmailLogsListResponse.new( + messages: Array(response[:messages]).map { |item| build_message_entity(item) }, + total_count: response[:total_count], + next_page_cursor: response[:next_page_cursor] + ) + end + + def handle_message_response(response) + build_message_entity(response) + end + + def build_message_entity(hash) + attrs = hash.slice(*EmailLogMessage.members) + attrs[:events] = build_events(attrs[:events]) if attrs[:events] + + EmailLogMessage.new(**attrs) + end + + def build_events(events_array) + Array(events_array).map do |e| + EmailLogEvent.new( + event_type: e[:event_type], + created_at: e[:created_at], + details: EmailLogEventDetails.build(e[:event_type], e[:details]) + ) + end + end + end +end diff --git a/lib/mailtrap/email_logs_list_response.rb b/lib/mailtrap/email_logs_list_response.rb new file mode 100644 index 0000000..c71ae1a --- /dev/null +++ b/lib/mailtrap/email_logs_list_response.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module Mailtrap + # Response from listing email logs (paginated) + # @see https://docs.mailtrap.io/developers/email-sending/email-logs + # @attr_reader messages [Array] Page of message summaries + # @attr_reader total_count [Integer] Total number of messages matching filters + # @attr_reader next_page_cursor [String, nil] Message UUID to use as search_after for next page, or nil + EmailLogsListResponse = Struct.new( + :messages, + :total_count, + :next_page_cursor, + keyword_init: true + ) +end diff --git a/spec/fixtures/vcr_cassettes/Mailtrap_EmailLogsAPI/_get/maps_response_data_to_EmailLogMessage_with_events_and_raw_message_url.yml b/spec/fixtures/vcr_cassettes/Mailtrap_EmailLogsAPI/_get/maps_response_data_to_EmailLogMessage_with_events_and_raw_message_url.yml new file mode 100644 index 0000000..ba683fa --- /dev/null +++ b/spec/fixtures/vcr_cassettes/Mailtrap_EmailLogsAPI/_get/maps_response_data_to_EmailLogMessage_with_events_and_raw_message_url.yml @@ -0,0 +1,77 @@ +--- +http_interactions: +- request: + method: get + uri: https://mailtrap.io/api/accounts/1111111/email_logs/a1b2c3d4-e5f6-7890-abcd-ef1234567890 + body: + encoding: US-ASCII + string: '' + headers: + Accept-Encoding: + - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 + Accept: + - "*/*" + User-Agent: + - mailtrap-ruby (https://github.com/mailtrap/mailtrap-ruby) + Host: + - mailtrap.io + Authorization: + - Bearer + Content-Type: + - application/json + response: + status: + code: 200 + message: OK + headers: + Date: + - Fri, 13 Mar 2026 13:44:38 GMT + Content-Type: + - application/json; charset=utf-8 + Transfer-Encoding: + - chunked + Connection: + - keep-alive + Server: + - cloudflare + Vary: + - Accept + - Accept-Encoding + X-Frame-Options: + - SAMEORIGIN + X-Xss-Protection: + - 1; mode=block + X-Content-Type-Options: + - nosniff + X-Download-Options: + - noopen + X-Permitted-Cross-Domain-Policies: + - none + Referrer-Policy: + - strict-origin-when-cross-origin + X-Mailtrap-Version: + - v2 + X-Ratelimit-Limit: + - '150' + X-Ratelimit-Remaining: + - '149' + Etag: + - W/"ab4786cd4b173b9f94a57ac540c3dc64" + Cache-Control: + - max-age=0, private, must-revalidate + X-Runtime: + - '0.038171' + Strict-Transport-Security: + - max-age=2592000; includeSubDomains; preload + Cf-Cache-Status: + - DYNAMIC + Alt-Svc: + - h3=":443"; ma=86400 + body: + encoding: ASCII-8BIT + string: '{"message_id":"a1b2c3d4-e5f6-7890-abcd-ef1234567890","status":"delivered","subject":"Test + Subject","from":"sender@example.com","to":"recipient@example.com","sent_at":"2025-01-15T10:30:00Z","client_ip":"192.0.2.1","category":"Test + Category","custom_variables":null,"sending_stream":"transactional","sending_domain_id":3938,"template_id":null,"template_variables":null,"raw_message_url":"https://example.com/raw/test-message.eml","events":[{"event_type":"delivery","created_at":"2025-01-15T10:30:01Z","details":{"sending_ip":"192.0.2.2","recipient_mx":"mx.example.com","email_service_provider":"Example + Provider"}}],"opens_count":0,"clicks_count":0}' + recorded_at: Fri, 13 Mar 2026 13:44:38 GMT +recorded_with: VCR 6.4.0 diff --git a/spec/fixtures/vcr_cassettes/Mailtrap_EmailLogsAPI/_get/when_message_not_found/raises_error.yml b/spec/fixtures/vcr_cassettes/Mailtrap_EmailLogsAPI/_get/when_message_not_found/raises_error.yml new file mode 100644 index 0000000..bcd7789 --- /dev/null +++ b/spec/fixtures/vcr_cassettes/Mailtrap_EmailLogsAPI/_get/when_message_not_found/raises_error.yml @@ -0,0 +1,71 @@ +--- +http_interactions: +- request: + method: get + uri: https://mailtrap.io/api/accounts/1111111/email_logs/00000000-0000-0000-0000-000000000000 + body: + encoding: US-ASCII + string: '' + headers: + Accept-Encoding: + - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 + Accept: + - "*/*" + User-Agent: + - mailtrap-ruby (https://github.com/mailtrap/mailtrap-ruby) + Host: + - mailtrap.io + Authorization: + - Bearer + Content-Type: + - application/json + response: + status: + code: 404 + message: Not Found + headers: + Date: + - Fri, 13 Mar 2026 13:44:45 GMT + Content-Type: + - application/json; charset=utf-8 + Content-Length: + - '21' + Connection: + - keep-alive + Server: + - cloudflare + X-Frame-Options: + - SAMEORIGIN + X-Xss-Protection: + - 1; mode=block + X-Content-Type-Options: + - nosniff + X-Download-Options: + - noopen + X-Permitted-Cross-Domain-Policies: + - none + Referrer-Policy: + - strict-origin-when-cross-origin + Vary: + - Accept + X-Mailtrap-Version: + - v2 + X-Ratelimit-Limit: + - '150' + X-Ratelimit-Remaining: + - '149' + Cache-Control: + - no-cache + X-Runtime: + - '0.029076' + Strict-Transport-Security: + - max-age=2592000; includeSubDomains; preload + Cf-Cache-Status: + - DYNAMIC + Alt-Svc: + - h3=":443"; ma=86400 + body: + encoding: UTF-8 + string: '{"error":"Not Found"}' + recorded_at: Fri, 13 Mar 2026 13:44:45 GMT +recorded_with: VCR 6.4.0 diff --git a/spec/fixtures/vcr_cassettes/Mailtrap_EmailLogsAPI/_list/maps_response_data_to_EmailLogsListResponse_with_messages.yml b/spec/fixtures/vcr_cassettes/Mailtrap_EmailLogsAPI/_list/maps_response_data_to_EmailLogsListResponse_with_messages.yml new file mode 100644 index 0000000..215b811 --- /dev/null +++ b/spec/fixtures/vcr_cassettes/Mailtrap_EmailLogsAPI/_list/maps_response_data_to_EmailLogsListResponse_with_messages.yml @@ -0,0 +1,75 @@ +--- +http_interactions: +- request: + method: get + uri: https://mailtrap.io/api/accounts/1111111/email_logs + body: + encoding: US-ASCII + string: '' + headers: + Accept-Encoding: + - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 + Accept: + - "*/*" + User-Agent: + - mailtrap-ruby (https://github.com/mailtrap/mailtrap-ruby) + Host: + - mailtrap.io + Authorization: + - Bearer + Content-Type: + - application/json + response: + status: + code: 200 + message: OK + headers: + Date: + - Fri, 13 Mar 2026 13:38:30 GMT + Content-Type: + - application/json; charset=utf-8 + Transfer-Encoding: + - chunked + Connection: + - keep-alive + Server: + - cloudflare + X-Frame-Options: + - SAMEORIGIN + X-Xss-Protection: + - 1; mode=block + X-Content-Type-Options: + - nosniff + X-Download-Options: + - noopen + X-Permitted-Cross-Domain-Policies: + - none + Referrer-Policy: + - strict-origin-when-cross-origin + Vary: + - Accept + X-Mailtrap-Version: + - v2 + X-Ratelimit-Limit: + - '150' + X-Ratelimit-Remaining: + - '149' + Etag: + - W/"5384eabf14d98cd54f79004f64855ec8" + Cache-Control: + - max-age=0, private, must-revalidate + X-Runtime: + - '0.052557' + Strict-Transport-Security: + - max-age=2592000; includeSubDomains; preload + Cf-Cache-Status: + - DYNAMIC + Alt-Svc: + - h3=":443"; ma=86400 + body: + encoding: ASCII-8BIT + string: '{"messages":[{"message_id":"a1b2c3d4-e5f6-7890-abcd-ef1234567890","status":"delivered","subject":"Test + Subject","from":"sender@example.com","to":"recipient@example.com","sent_at":"2025-01-15T10:30:00Z","client_ip":"192.0.2.1","category":"Test + Category","custom_variables":null,"sending_stream":"transactional","sending_domain_id":3938,"template_id":null,"template_variables":null,"opens_count":0,"clicks_count":0}],"total_count":1,"next_page_cursor":null}' + recorded_at: Fri, 13 Mar 2026 13:38:30 GMT +recorded_with: VCR 6.4.0 diff --git a/spec/fixtures/vcr_cassettes/Mailtrap_EmailLogsAPI/_list/with_filters_and_search_after/sends_correct_query_params_and_maps_response_data_to_EmailLogsListResponse_with_messages.yml b/spec/fixtures/vcr_cassettes/Mailtrap_EmailLogsAPI/_list/with_filters_and_search_after/sends_correct_query_params_and_maps_response_data_to_EmailLogsListResponse_with_messages.yml new file mode 100644 index 0000000..d1cb08b --- /dev/null +++ b/spec/fixtures/vcr_cassettes/Mailtrap_EmailLogsAPI/_list/with_filters_and_search_after/sends_correct_query_params_and_maps_response_data_to_EmailLogsListResponse_with_messages.yml @@ -0,0 +1,73 @@ +--- +http_interactions: +- request: + method: get + uri: https://mailtrap.io/api/accounts/1111111/email_logs?filters%5Bcategory%5D%5Boperator%5D=equal&filters%5Bcategory%5D%5Bvalue%5D=Test%20Category&filters%5Bsent_after%5D=2025-01-01T00:00:00Z&filters%5Bsent_before%5D=2025-01-31T23:59:59Z&filters%5Bsubject%5D%5Boperator%5D=not_empty&filters%5Bto%5D%5Boperator%5D=ci_equal&filters%5Bto%5D%5Bvalue%5D=recipient@example.com&search_after=a1b2c3d4-e5f6-7890-abcd-ef1234567890 + body: + encoding: US-ASCII + string: '' + headers: + Accept-Encoding: + - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 + Accept: + - "*/*" + User-Agent: + - mailtrap-ruby (https://github.com/mailtrap/mailtrap-ruby) + Host: + - mailtrap.io + Authorization: + - Bearer + Content-Type: + - application/json + response: + status: + code: 200 + message: OK + headers: + Date: + - Fri, 13 Mar 2026 13:41:12 GMT + Content-Type: + - application/json; charset=utf-8 + Transfer-Encoding: + - chunked + Connection: + - keep-alive + Server: + - cloudflare + X-Frame-Options: + - SAMEORIGIN + X-Xss-Protection: + - 1; mode=block + X-Content-Type-Options: + - nosniff + X-Download-Options: + - noopen + X-Permitted-Cross-Domain-Policies: + - none + Referrer-Policy: + - strict-origin-when-cross-origin + Vary: + - Accept + X-Mailtrap-Version: + - v2 + X-Ratelimit-Limit: + - '150' + X-Ratelimit-Remaining: + - '149' + Etag: + - W/"56af3aa8a5f82811c9435c02d0a3af68" + Cache-Control: + - max-age=0, private, must-revalidate + X-Runtime: + - '0.055468' + Strict-Transport-Security: + - max-age=2592000; includeSubDomains; preload + Cf-Cache-Status: + - DYNAMIC + Alt-Svc: + - h3=":443"; ma=86400 + body: + encoding: ASCII-8BIT + string: '{"messages":[],"total_count":1,"next_page_cursor":null}' + recorded_at: Fri, 13 Mar 2026 13:41:12 GMT +recorded_with: VCR 6.4.0 diff --git a/spec/mailtrap/email_log_event_details_spec.rb b/spec/mailtrap/email_log_event_details_spec.rb new file mode 100644 index 0000000..2568940 --- /dev/null +++ b/spec/mailtrap/email_log_event_details_spec.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +RSpec.describe Mailtrap::EmailLogEventDetails do + describe '.build' do + it 'returns Delivery struct for delivery event_type' do + details = described_class.build( + 'delivery', + { + sending_ip: '192.0.2.1', + recipient_mx: 'mx.example.com', + email_service_provider: 'Example Provider' + } + ) + expect(details).to be_a(described_class::Delivery) + expect(details).to have_attributes( + sending_ip: '192.0.2.1', + recipient_mx: 'mx.example.com', + email_service_provider: 'Example Provider' + ) + end + + it 'returns Click struct for click event_type' do + details = described_class.build( + 'click', + { + click_url: 'https://example.com/link', + web_ip_address: '198.51.100.50' + } + ) + expect(details).to be_a(described_class::Click) + expect(details).to have_attributes( + click_url: 'https://example.com/link', + web_ip_address: '198.51.100.50' + ) + end + + it 'returns Bounce struct for bounce event_type' do + details = described_class.build( + 'bounce', { + bounce_category: 'invalid_recipient', + email_service_provider_response: 'User unknown' + } + ) + expect(details).to be_a(described_class::Bounce) + expect(details).to have_attributes( + bounce_category: 'invalid_recipient', + email_service_provider_response: 'User unknown' + ) + end + end +end diff --git a/spec/mailtrap/email_log_event_spec.rb b/spec/mailtrap/email_log_event_spec.rb new file mode 100644 index 0000000..194837b --- /dev/null +++ b/spec/mailtrap/email_log_event_spec.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +RSpec.describe Mailtrap::EmailLogEvent do + let(:attributes) do + { + event_type: 'click', + created_at: '2025-01-15T10:35:00Z', + details: { click_url: 'https://example.com/track/abc', web_ip_address: '198.51.100.50' } + } + end + + describe '#initialize' do + subject(:event) { described_class.new(attributes) } + + it 'creates an event with all attributes' do + expect(event).to have_attributes(attributes) + end + end +end diff --git a/spec/mailtrap/email_log_message_spec.rb b/spec/mailtrap/email_log_message_spec.rb new file mode 100644 index 0000000..f9b0344 --- /dev/null +++ b/spec/mailtrap/email_log_message_spec.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +RSpec.describe Mailtrap::EmailLogMessage do + let(:attributes) do + { + message_id: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890', + status: 'delivered', + subject: 'Welcome', + from: 'sender@example.com', + to: 'recipient@example.com', + sent_at: '2025-01-15T10:30:00Z', + client_ip: '203.0.113.42', + category: 'Welcome Email', + custom_variables: {}, + sending_stream: 'transactional', + sending_domain_id: 3938, + template_id: 100, + template_variables: {}, + opens_count: 2, + clicks_count: 1 + } + end + + describe '#initialize' do + subject(:message) { described_class.new(attributes) } + + it 'creates a message with all attributes' do + expect(message).to have_attributes(attributes) + end + end +end diff --git a/spec/mailtrap/email_logs_api_spec.rb b/spec/mailtrap/email_logs_api_spec.rb new file mode 100644 index 0000000..50720ae --- /dev/null +++ b/spec/mailtrap/email_logs_api_spec.rb @@ -0,0 +1,118 @@ +# frozen_string_literal: true + +RSpec.describe Mailtrap::EmailLogsAPI, :vcr do + subject(:email_logs) { described_class.new(account_id, client) } + + let(:account_id) { ENV.fetch('MAILTRAP_ACCOUNT_ID', 1_111_111) } + let(:client) { Mailtrap::Client.new(api_key: ENV.fetch('MAILTRAP_API_KEY', 'local-api-key')) } + + describe '#list' do + subject(:list) { email_logs.list } + + it 'maps response data to EmailLogsListResponse with messages' do + expect(list.total_count).to eq(1) + expect(list.next_page_cursor).to be_nil + expect(list.messages).to all(be_a(Mailtrap::EmailLogMessage)) + expect(list.messages.first).to have_attributes( + message_id: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890', + status: 'delivered', + subject: 'Test Subject', + from: 'sender@example.com', + to: 'recipient@example.com', + sent_at: '2025-01-15T10:30:00Z', + client_ip: '192.0.2.1', + category: 'Test Category', + sending_stream: 'transactional', + sending_domain_id: 3938, + raw_message_url: nil, + opens_count: 0, + clicks_count: 0, + events: nil + ) + end + + context 'with filters and search_after' do + subject(:list) do + email_logs.list( + filters: { + sent_after: '2025-01-01T00:00:00Z', + sent_before: '2025-01-31T23:59:59Z', + subject: { operator: 'not_empty' }, + to: { operator: 'ci_equal', value: 'recipient@example.com' }, + category: { operator: 'equal', value: ['Test Category'] } + }, + search_after: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890' + ) + end + + it 'sends correct query params and maps response data to EmailLogsListResponse with messages' do + allow(client).to receive(:get).and_call_original + + expect(list).to be_a(Mailtrap::EmailLogsListResponse) + expect(list.messages).to all(be_a(Mailtrap::EmailLogMessage)) + + expect(client).to have_received(:get).with( + '/api/accounts/1111111/email_logs', + { + 'filters[category][operator]' => 'equal', + 'filters[category][value]' => ['Test Category'], + 'filters[sent_after]' => '2025-01-01T00:00:00Z', + 'filters[sent_before]' => '2025-01-31T23:59:59Z', + 'filters[subject][operator]' => 'not_empty', + 'filters[to][operator]' => 'ci_equal', + 'filters[to][value]' => 'recipient@example.com', + search_after: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890' + } + ) + end + end + end + + describe '#get' do + subject(:message) { email_logs.get(message_id) } + + let(:message_id) { 'a1b2c3d4-e5f6-7890-abcd-ef1234567890' } + + it 'maps response data to EmailLogMessage with events and raw_message_url' do # rubocop:disable RSpec/MultipleExpectations + expect(message).to be_a(Mailtrap::EmailLogMessage) + expect(message).to have_attributes( + message_id: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890', + status: 'delivered', + subject: 'Test Subject', + from: 'sender@example.com', + to: 'recipient@example.com', + sent_at: '2025-01-15T10:30:00Z', + client_ip: '192.0.2.1', + category: 'Test Category', + sending_stream: 'transactional', + sending_domain_id: 3938, + raw_message_url: 'https://example.com/raw/test-message.eml', + opens_count: 0, + clicks_count: 0 + ) + expect(message.events).to all(be_a(Mailtrap::EmailLogEvent)) + expect(message.events.first).to have_attributes( + event_type: 'delivery', + created_at: '2025-01-15T10:30:01Z' + ) + expect(message.events.first.details).to be_a(Mailtrap::EmailLogEventDetails::Delivery) + expect(message.events.first.details).to have_attributes( + sending_ip: '192.0.2.2', + recipient_mx: 'mx.example.com', + email_service_provider: 'Example Provider' + ) + end + + context 'when message not found' do + let(:message_id) { '00000000-0000-0000-0000-000000000000' } + + it 'raises error' do + expect { message }.to raise_error do |error| + expect(error).to be_a(Mailtrap::Error) + expect(error.message).to include('Not Found') + expect(error.messages.any? { |msg| msg.include?('Not Found') }).to be true + end + end + end + end +end