diff --git a/README.md b/README.md index afa65f4..562275e 100644 --- a/README.md +++ b/README.md @@ -204,6 +204,7 @@ Contact management: Email marketing: - Email Campaigns CRUD, Lifecycle Actions & Stats – [`email_campaigns_api.rb`](examples/email_campaigns_api.rb) +- Email Marketing Stats API – [`marketing_stats_api.rb`](examples/marketing_stats_api.rb) General: diff --git a/examples/marketing_stats_api.rb b/examples/marketing_stats_api.rb new file mode 100644 index 0000000..dddafaa --- /dev/null +++ b/examples/marketing_stats_api.rb @@ -0,0 +1,28 @@ +require 'mailtrap' +require 'date' + +client = Mailtrap::Client.new(api_key: 'your-api-key') +marketing_stats = Mailtrap::MarketingStatsAPI.new(client) + +today = Date.today + +# Get aggregated Email Marketing stats (campaigns and automations) for the last 30 days +marketing_stats.get(start_date: today - 29, end_date: today) +# => # + +# Narrow to campaigns only (or automations only), for some sending domains and email service providers +marketing_stats.get( + start_date: today - 29, + end_date: today, + sending_domain_ids: [3938, 3939], + sources: ['campaigns'], + email_service_providers: %w[Google Yahoo] +) +# => # diff --git a/lib/mailtrap.rb b/lib/mailtrap.rb index aa2fafa..6f739f3 100644 --- a/lib/mailtrap.rb +++ b/lib/mailtrap.rb @@ -21,6 +21,7 @@ require_relative 'mailtrap/suppressions_api' require_relative 'mailtrap/tracking_opt_outs_api' require_relative 'mailtrap/email_campaigns_api' +require_relative 'mailtrap/marketing_stats_api' require_relative 'mailtrap/sending_domains_api' require_relative 'mailtrap/company_info_api' require_relative 'mailtrap/email_logs_api' diff --git a/lib/mailtrap/marketing_stats.rb b/lib/mailtrap/marketing_stats.rb new file mode 100644 index 0000000..534d999 --- /dev/null +++ b/lib/mailtrap/marketing_stats.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +module Mailtrap + # Data Transfer Object for Email Marketing Stats + # + # Aggregated metrics of the account's Email Marketing traffic (campaigns and automations) for + # the requested period and filters. Rates are fractions from 0 to 1. Every count and rate is +0+ + # when nothing matched. + # @see https://docs.mailtrap.io/developers/email-marketing/marketing-stats + # @attr_reader delivery_count [Integer] Number of delivered messages + # @attr_reader delivery_rate [Float, Integer] Share of sent messages that were delivered (0–1) + # @attr_reader bounce_count [Integer] Number of bounced messages + # @attr_reader bounce_rate [Float, Integer] Share of sent messages that bounced (0–1) + # @attr_reader open_count [Integer] Number of opened messages + # @attr_reader open_rate [Float, Integer] Share of delivered messages that were opened (0–1) + # @attr_reader click_count [Integer] Number of clicked messages + # @attr_reader click_rate [Float, Integer] Share of delivered messages that were clicked (0–1) + # @attr_reader spam_count [Integer] Number of spam complaints + # @attr_reader spam_rate [Float, Integer] Share of delivered messages marked as spam (0–1) + # @attr_reader unsubscription_count [Integer] Number of unsubscriptions + # @attr_reader unsubscription_rate [Float, Integer] Share of delivered messages that unsubscribed (0–1) + # @attr_reader sent_count [Integer] Number of sent messages (delivered plus bounced) + MarketingStats = Struct.new( + :delivery_count, + :delivery_rate, + :bounce_count, + :bounce_rate, + :open_count, + :open_rate, + :click_count, + :click_rate, + :spam_count, + :spam_rate, + :unsubscription_count, + :unsubscription_rate, + :sent_count, + keyword_init: true + ) +end diff --git a/lib/mailtrap/marketing_stats_api.rb b/lib/mailtrap/marketing_stats_api.rb new file mode 100644 index 0000000..60120a3 --- /dev/null +++ b/lib/mailtrap/marketing_stats_api.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +require_relative 'base_api' +require_relative 'stats_query_params' +require_relative 'marketing_stats' + +module Mailtrap + class MarketingStatsAPI + include BaseAPI + include StatsQueryParams + + # @param client [Mailtrap::Client] The client instance + def initialize(client = Mailtrap::Client.new) + @client = client + end + + # Get aggregated Email Marketing stats: campaigns and automations together, the same numbers + # as Email Marketing > Stats Overview in the app. Email API/SMTP traffic is reported by {StatsAPI}. + # @param start_date [String, Date, Time] Start of the period, inclusive (required) + # @param end_date [String, Date, Time] End of the period, inclusive (required) + # @param sending_domain_ids [Array] Filter by sending domain IDs + # @param sources [Array] Filter by source: +campaigns+ or +automations+; + # omit it, or pass both, to include campaigns and automations together + # @param email_service_providers [Array] Filter by recipient email service providers + # @return [MarketingStats] Aggregated Email Marketing stats + # @!macro api_errors + # @raise [ArgumentError] If a date is not a Date, Time, or String in +YYYY-MM-DD+ format + def get(start_date:, end_date:, sending_domain_ids: nil, sources: nil, email_service_providers: nil) + query_params = build_query_params( + start_date, end_date, + { sending_domain_ids:, sources:, email_service_providers: } + ) + response = client.get(base_path, query_params) + build_entity(response[:data], MarketingStats) + end + + private + + def base_path + '/api/marketing/stats' + end + end +end diff --git a/lib/mailtrap/stats_api.rb b/lib/mailtrap/stats_api.rb index dfdd8ef..d4c867c 100644 --- a/lib/mailtrap/stats_api.rb +++ b/lib/mailtrap/stats_api.rb @@ -1,15 +1,15 @@ # frozen_string_literal: true -require 'date' require_relative 'base_api' +require_relative 'stats_query_params' require_relative 'sending_stats' require_relative 'sending_stat_group' module Mailtrap class StatsAPI include BaseAPI + include StatsQueryParams - ARRAY_FILTERS = %i[sending_domain_ids sending_streams categories email_service_providers].freeze GROUP_KEYS = { 'domains' => :sending_domain_id, 'categories' => :category, @@ -112,38 +112,6 @@ def grouped_stats(group, start_date, end_date, filters) end end - def build_query_params(start_date, end_date, filters) - params = { start_date: normalize_date(start_date), end_date: normalize_date(end_date) } - - ARRAY_FILTERS.each do |filter_key| - values = filters[filter_key] - params["#{filter_key}[]"] = values if values - end - - params - end - - def normalize_date(value) - case value - when Date - value.iso8601 - when Time - value.strftime('%F') - when String - unless /\A\d{4}-\d{2}-\d{2}\z/.match?(value) - raise ArgumentError, - "Invalid date: #{value.inspect}. Expected a Date, Time, or String in YYYY-MM-DD format." - end - - Date.iso8601(value).iso8601 - else - raise ArgumentError, - "Invalid date: #{value.inspect}. Expected a Date, Time, or String in YYYY-MM-DD format." - end - rescue Date::Error - raise ArgumentError, "Invalid date: #{value.inspect}. Expected a Date, Time, or String in YYYY-MM-DD format." - end - def base_path "/api/accounts/#{account_id}/stats" end diff --git a/lib/mailtrap/stats_query_params.rb b/lib/mailtrap/stats_query_params.rb new file mode 100644 index 0000000..4149d8a --- /dev/null +++ b/lib/mailtrap/stats_query_params.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +require 'date' + +module Mailtrap + # Query parameters of the sending stats and Email Marketing stats endpoints: the period as + # +YYYY-MM-DD+ dates and every array filter as a repeated +key[]+ parameter. A +nil+ or empty + # filter is left out. + module StatsQueryParams + private + + def build_query_params(start_date, end_date, filters) + params = { start_date: normalize_date(start_date), end_date: normalize_date(end_date) } + filters.each { |filter_key, values| params["#{filter_key}[]"] = values unless Array(values).empty? } + params + end + + def normalize_date(value) + case value + when Date, Time # DateTime is a Date, but its #iso8601 includes the time of day + return value.strftime('%F') + when String + return value if /\A\d{4}-\d{2}-\d{2}\z/.match?(value) && Date.valid_date?(*value.split('-').map(&:to_i)) + end + + raise ArgumentError, "Invalid date: #{value.inspect}. Expected a Date, Time, or String in YYYY-MM-DD format." + end + end +end diff --git a/spec/fixtures/vcr_cassettes/Mailtrap_MarketingStatsAPI/vcr_get/maps_response_data_to_a_MarketingStats_object.yml b/spec/fixtures/vcr_cassettes/Mailtrap_MarketingStatsAPI/vcr_get/maps_response_data_to_a_MarketingStats_object.yml new file mode 100644 index 0000000..af516cb --- /dev/null +++ b/spec/fixtures/vcr_cassettes/Mailtrap_MarketingStatsAPI/vcr_get/maps_response_data_to_a_MarketingStats_object.yml @@ -0,0 +1,40 @@ +# Hand-written from the documented response until GET /api/marketing/stats is live. +# Re-record it then: delete this file and run +# bin/record-vcr spec/mailtrap/marketing_stats_api_spec.rb +--- +http_interactions: +- request: + method: get + uri: https://mailtrap.io/api/marketing/stats?end_date=2026-09-30&sources%5B%5D=campaigns&start_date=2026-09-01 + 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: + - Tue, 13 Oct 2026 10:00:00 GMT + Content-Type: + - application/json; charset=utf-8 + X-Mailtrap-Version: + - v2 + body: + encoding: ASCII-8BIT + string: '{"data":{"delivery_count":1200,"delivery_rate":0.96,"bounce_count":50,"bounce_rate":0.04,"open_count":600,"open_rate":0.5,"click_count":240,"click_rate":0.2,"spam_count":3,"spam_rate":0.0025,"unsubscription_count":12,"unsubscription_rate":0.01,"sent_count":1250}}' + recorded_at: Tue, 13 Oct 2026 10:00:00 GMT +recorded_with: VCR 6.4.0 diff --git a/spec/mailtrap/marketing_stats_api_spec.rb b/spec/mailtrap/marketing_stats_api_spec.rb new file mode 100644 index 0000000..74244af --- /dev/null +++ b/spec/mailtrap/marketing_stats_api_spec.rb @@ -0,0 +1,132 @@ +# frozen_string_literal: true + +RSpec.describe Mailtrap::MarketingStatsAPI do + subject(:marketing_stats_api) { described_class.new(Mailtrap::Client.new(api_key: 'correct-api-key')) } + + let(:base_url) { 'https://mailtrap.io/api/marketing/stats' } + let(:stats_attributes) do + { + delivery_count: 1200, + delivery_rate: 0.96, + bounce_count: 50, + bounce_rate: 0.04, + open_count: 600, + open_rate: 0.5, + click_count: 240, + click_rate: 0.2, + spam_count: 3, + spam_rate: 0.0025, + unsubscription_count: 12, + unsubscription_rate: 0.01, + sent_count: 1250 + } + end + let(:success_response) do + { status: 200, body: { 'data' => stats_attributes }.to_json, headers: { 'Content-Type' => 'application/json' } } + end + + describe '#get' do + subject(:stats) { marketing_stats_api.get(start_date: '2026-05-01', end_date: '2026-05-31') } + + it 'returns a MarketingStats object' do + stub = stub_request(:get, "#{base_url}?start_date=2026-05-01&end_date=2026-05-31") + .to_return(success_response) + + expect(stats).to be_a(Mailtrap::MarketingStats) + expect(stats).to match_struct(stats_attributes) + expect(stub).to have_been_requested + end + + context 'with optional filters' do + subject(:stats) do + marketing_stats_api.get( + start_date: '2026-05-01', + end_date: '2026-05-31', + sending_domain_ids: [3938, 3939], + sources: ['campaigns'], + email_service_providers: %w[Google Yahoo] + ) + end + + it 'sends every filter as an array query parameter' do + stub = stub_request( + :get, + "#{base_url}?start_date=2026-05-01&end_date=2026-05-31" \ + '&sending_domain_ids[]=3938&sending_domain_ids[]=3939&sources[]=campaigns' \ + '&email_service_providers[]=Google&email_service_providers[]=Yahoo' + ).to_return(success_response) + + expect(stats).to have_attributes(delivery_count: 1200, sent_count: 1250) + expect(stub).to have_been_requested + end + end + + it 'sends DateTime and Time arguments as plain dates' do + stub = stub_request(:get, "#{base_url}?start_date=2026-05-01&end_date=2026-05-31") + .to_return(success_response) + + marketing_stats_api.get(start_date: DateTime.new(2026, 5, 1, 13, 45), end_date: Time.new(2026, 5, 31, 23, 59)) + + expect(stub).to have_been_requested + end + + it 'leaves out empty filters' do + allow(marketing_stats_api.client).to receive(:get).and_return(data: stats_attributes) + + marketing_stats_api.get(start_date: '2026-05-01', end_date: '2026-05-31', sending_domain_ids: [], + sources: ['campaigns']) + + expect(marketing_stats_api.client).to have_received(:get).with( + '/api/marketing/stats', + { start_date: '2026-05-01', end_date: '2026-05-31', 'sources[]' => ['campaigns'] } + ) + end + + it 'raises ArgumentError for an invalid date' do + expect { marketing_stats_api.get(start_date: '2026-05-01', end_date: '2026-13-01') } + .to raise_error(ArgumentError, /Invalid date: "2026-13-01"/) + end + + it 'raises error when a filter is rejected' do + stub_request(:get, "#{base_url}?start_date=2026-05-01&end_date=2026-05-31&sources[]=newsletters") + .to_return( + status: 400, + body: { 'errors' => "Invalid source: 'newsletters'" }.to_json, + headers: { 'Content-Type' => 'application/json' } + ) + + expect { marketing_stats_api.get(start_date: '2026-05-01', end_date: '2026-05-31', sources: ['newsletters']) } + .to raise_error(Mailtrap::Error, "Invalid source: 'newsletters'") + end + + it 'raises authorization error when the API key is incorrect' do + stub_request(:get, "#{base_url}?start_date=2026-05-01&end_date=2026-05-31") + .to_return( + status: 401, + body: { 'error' => 'Incorrect API token' }.to_json, + headers: { 'Content-Type' => 'application/json' } + ) + + expect { stats }.to raise_error(Mailtrap::AuthorizationError, 'Incorrect API token') + end + end + + describe 'vcr#get', :vcr do + subject(:stats) do + described_class.new(client).get(start_date: '2026-09-01', end_date: '2026-09-30', sources: ['campaigns']) + end + + let(:client) { Mailtrap::Client.new(api_key: ENV.fetch('MAILTRAP_API_KEY', 'local-api-key')) } + + it 'maps response data to a MarketingStats object' do + expect(stats).to be_a(Mailtrap::MarketingStats) + expect(stats).to have_attributes( + delivery_count: be_a(Integer), + delivery_rate: be_a(Numeric), + open_count: be_a(Integer), + open_rate: be_a(Numeric), + sent_count: be_a(Integer) + ) + end + end +end diff --git a/spec/mailtrap/marketing_stats_spec.rb b/spec/mailtrap/marketing_stats_spec.rb new file mode 100644 index 0000000..7196853 --- /dev/null +++ b/spec/mailtrap/marketing_stats_spec.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +RSpec.describe Mailtrap::MarketingStats do + describe '#initialize' do + subject(:marketing_stats) { described_class.new(attributes) } + + let(:attributes) do + { + delivery_count: 1200, + delivery_rate: 0.96, + bounce_count: 50, + bounce_rate: 0.04, + open_count: 600, + open_rate: 0.5, + click_count: 240, + click_rate: 0.2, + spam_count: 3, + spam_rate: 0.0025, + unsubscription_count: 12, + unsubscription_rate: 0.01, + sent_count: 1250 + } + end + + it 'creates marketing stats with all attributes' do + expect(marketing_stats).to match_struct(attributes) + end + end +end diff --git a/spec/mailtrap/stats_api_spec.rb b/spec/mailtrap/stats_api_spec.rb index 5400539..378a195 100644 --- a/spec/mailtrap/stats_api_spec.rb +++ b/spec/mailtrap/stats_api_spec.rb @@ -199,6 +199,12 @@ expect(stats).to be_a(Mailtrap::SendingStats) end + it 'sends DateTime objects as plain dates', + vcr: { cassette_name: 'Mailtrap_StatsAPI/_get/returns_aggregated_sending_stats' } do + stats = stats_api.get(start_date: DateTime.new(2026, 1, 1, 13, 45), end_date: DateTime.new(2026, 1, 31, 23, 59)) + expect(stats).to be_a(Mailtrap::SendingStats) + end + it 'raises ArgumentError for invalid start_date' do expect { stats_api.get(start_date: 'not-a-date', end_date: end_date) } .to raise_error(ArgumentError, /Invalid date: "not-a-date"/)