diff --git a/README.md b/README.md index 1b669971..85d016fa 100644 --- a/README.md +++ b/README.md @@ -198,6 +198,10 @@ Contact management: - Contacts CRUD & Listing – [`contacts_api.rb`](examples/contacts_api.rb) +Email marketing: + +- Email Campaigns CRUD, Lifecycle Actions & Stats – [`email_campaigns_api.rb`](examples/email_campaigns_api.rb) + General: - Accounts API – [`accounts_api.rb`](examples/accounts_api.rb) diff --git a/examples/email_campaigns_api.rb b/examples/email_campaigns_api.rb new file mode 100644 index 00000000..e37ae8e1 --- /dev/null +++ b/examples/email_campaigns_api.rb @@ -0,0 +1,76 @@ +require 'mailtrap' + +client = Mailtrap::Client.new(api_key: 'your-api-key') +email_campaigns = Mailtrap::EmailCampaignsAPI.new(client) + +# Create a new Email Campaign (always created in the draft state) +email_campaign = email_campaigns.create( + name: 'Spring Sale', + mailsend_domain_id: 'd2313359-acb4-4b87-bce6-f5774f6a1e37', + from_display_name: 'Acme Marketing', + from_local_part: 'news', + reply_to: { + display_name: 'Acme Support', + local_part: 'support', + domain: 'acme.com' + }, + template_attributes: { subject: 'Spring is here — 30% off' } +) +# => # + +# Get all Email Campaigns (paginated, newest first; filter by name) +list = email_campaigns.list(per_page: 50, name: 'Spring') +# => #], pagination={...}> +list.data +# => [#] +list.pagination +# => {:token=>1, :prev_token=>nil, :next_token=>2, ...} + +# Get a single Email Campaign +email_campaign = email_campaigns.get(email_campaign.id) +# => # + +# Update a draft Email Campaign (partial; add the design and the audience) +email_campaigns.update( + email_campaign.id, + name: 'Spring Sale (updated)', + template_attributes: { + subject: 'New subject', + body_html: '

Hi {{first_name}}!

' \ + '

Unsubscribe

', + merge_tags: ['first_name'] + }, + delivery_mode: 'gradual', + delivery_options: { emails_per_hour: 1000 }, + contact_list_ids: [55, 56], + contact_segment_ids: [12] +) +# => # + +# Schedule the draft Email Campaign to start sending at a future time +email_campaigns.schedule(email_campaign.id, '2026-06-01T09:00:00.000Z') +# => # + +# Cancel the scheduled Email Campaign (returns it to the draft state) +email_campaigns.cancel(email_campaign.id) +# => # + +# Start sending the draft Email Campaign immediately +email_campaigns.start(email_campaign.id) +# => # + +# Terminate a sending Email Campaign +email_campaigns.terminate(email_campaign.id) +# => # + +# Reset a scheduled Email Campaign back to draft +email_campaigns.reset(email_campaign.id) +# => # + +# Get Email Campaign statistics (optionally narrow the aggregation window) +email_campaigns.stats(email_campaign.id, start_date: '2026-05-01', end_date: '2026-05-31') +# => # + +# Delete an Email Campaign (returns nil; the campaign must not be in a sending state) +email_campaigns.delete(email_campaign.id) +# => nil diff --git a/lib/mailtrap.rb b/lib/mailtrap.rb index ff0103d1..01aa2ba2 100644 --- a/lib/mailtrap.rb +++ b/lib/mailtrap.rb @@ -18,6 +18,7 @@ require_relative 'mailtrap/contact_exports_api' require_relative 'mailtrap/contact_events_api' require_relative 'mailtrap/suppressions_api' +require_relative 'mailtrap/email_campaigns_api' require_relative 'mailtrap/sending_domains_api' require_relative 'mailtrap/company_info_api' require_relative 'mailtrap/email_logs_api' diff --git a/lib/mailtrap/email_campaign.rb b/lib/mailtrap/email_campaign.rb new file mode 100644 index 00000000..3f059b80 --- /dev/null +++ b/lib/mailtrap/email_campaign.rb @@ -0,0 +1,104 @@ +# frozen_string_literal: true + +module Mailtrap + # Data Transfer Object for Email Campaign Stats + # + # Aggregated campaign performance metrics. All counts and rates are +0+ when the + # campaign has not been started. + # @see https://api-docs.mailtrap.io/docs/mailtrap-api-docs/email-campaigns + # @attr_reader delivery_count [Integer] Number of delivered messages + # @attr_reader open_count [Integer] Number of opened messages + # @attr_reader click_count [Integer] Number of clicked messages + # @attr_reader bounce_count [Integer] Number of bounced messages + # @attr_reader unsubscription_count [Integer] Number of unsubscriptions + # @attr_reader sent_count [Integer] Number of sent messages + # @attr_reader spam_count [Integer] Number of spam complaints + # @attr_reader message_count [Integer] Total number of messages + # @attr_reader reject_count [Integer] Number of rejected messages + # @attr_reader delivery_rate [Float] Share of sent messages that were delivered (0–1) + # @attr_reader open_rate [Float] Share of delivered messages that were opened (0–1) + # @attr_reader click_rate [Float] Share of delivered messages that were clicked (0–1) + # @attr_reader bounce_rate [Float] Share of sent messages that bounced (0–1) + # @attr_reader spam_rate [Float] Share of sent messages marked as spam (0–1) + # @attr_reader unsubscription_rate [Float] Share of delivered messages that unsubscribed (0–1) + EmailCampaignStats = Struct.new( + :delivery_count, + :open_count, + :click_count, + :bounce_count, + :unsubscription_count, + :sent_count, + :spam_count, + :message_count, + :reject_count, + :delivery_rate, + :open_rate, + :click_rate, + :bounce_rate, + :spam_rate, + :unsubscription_rate, + keyword_init: true + ) + + # Data Transfer Object for Email Campaign + # @see https://api-docs.mailtrap.io/docs/mailtrap-api-docs/email-campaigns + # @attr_reader id [Integer] The email campaign ID + # @attr_reader type [String] Resource type discriminator + # (+ContactsEmailCampaign+ or +RecipientsEmailCampaign+) + # @attr_reader mailsend_domain_id [String] UUID of the sending domain used for the campaign + # @attr_reader mailsend_domain_name [String] Name of the sending domain used for the campaign + # @attr_reader name [String] Campaign name + # @attr_reader from_local_part [String] Local part (before the @) of the From address + # @attr_reader from_display_name [String] Display name shown in the From header + # @attr_reader reply_to [Hash, nil] Reply-To address parts (+display_name+, +local_part+, +domain+) + # @attr_reader current_state [String] Lifecycle state (+draft+, +scheduled+, +started+, +queued+, + # +paused+, +terminating+, +under_review+, +finished+, +failed+, +failed_immediately+) + # @attr_reader current_state_metadata [Hash, nil] Metadata about the most recent state transition + # (+reason+, +error+, +errors+ as an array of +{message, rcpt_index}+ objects, +scheduled_at+) + # @attr_reader created_at [String] The creation timestamp + # @attr_reader updated_at [String] The last update timestamp + # @attr_reader last_started_at [String, nil] When the campaign was last started, or +nil+ + # @attr_reader last_started_at_date [String, nil] Date the campaign was last started, present only when started + # @attr_reader recipient_total_count [Integer, nil] Total number of recipients, + # or +nil+ until the audience is resolved + # @attr_reader contact_list_ids [Array] IDs of the contact lists included in the campaign's audience + # @attr_reader contact_segment_ids [Array] IDs of the contact segments included in the campaign's audience + # @attr_reader delivery_mode [String] How the campaign is delivered (+rapid+ or +gradual+) + # @attr_reader delivery_options [Hash, nil] Delivery throttling options (+emails_per_hour+) + # @attr_reader template [Hash, nil] The campaign template (+id+, +subject+, +merge_tags+, + # +body_html+, +body_text+; bodies are omitted on list responses) + EmailCampaign = Struct.new( + :id, + :type, + :mailsend_domain_id, + :mailsend_domain_name, + :name, + :from_local_part, + :from_display_name, + :reply_to, + :current_state, + :current_state_metadata, + :created_at, + :updated_at, + :last_started_at, + :last_started_at_date, + :recipient_total_count, + :contact_list_ids, + :contact_segment_ids, + :delivery_mode, + :delivery_options, + :template, + keyword_init: true + ) + + # Response from listing email campaigns (paginated) + # @see https://api-docs.mailtrap.io/docs/mailtrap-api-docs/email-campaigns + # @attr_reader data [Array] Page of email campaigns, newest first + # @attr_reader pagination [Hash] Page-token pagination metadata + # (+token+, +prev_token+, +next_token+, +first_url+, +prev_url+, +current_url+, +next_url+) + EmailCampaignsListResponse = Struct.new( + :data, + :pagination, + keyword_init: true + ) +end diff --git a/lib/mailtrap/email_campaigns_api.rb b/lib/mailtrap/email_campaigns_api.rb new file mode 100644 index 00000000..9ed2c585 --- /dev/null +++ b/lib/mailtrap/email_campaigns_api.rb @@ -0,0 +1,187 @@ +# frozen_string_literal: true + +require_relative 'base_api' +require_relative 'email_campaign' + +module Mailtrap + class EmailCampaignsAPI + include BaseAPI + + self.supported_options = %i[ + name + mailsend_domain_id + from_display_name + from_local_part + reply_to + template_attributes + delivery_mode + delivery_options + contact_list_ids + contact_segment_ids + ].freeze + + self.response_class = EmailCampaign + + attr_reader :client + + # @param client [Mailtrap::Client] The client instance + def initialize(client = Mailtrap::Client.new) + @client = client + end + + # Lists email campaigns for the account, newest first + # @param per_page [Integer, nil] Number of campaigns per page (max 100, default 50) + # @param name [String, nil] Filter campaigns by name + # @param token [Integer, nil] Page number to retrieve (page-token pagination, default 1) + # @return [EmailCampaignsListResponse] The page of campaigns and pagination metadata + # @!macro api_errors + def list(per_page: nil, name: nil, token: nil) + query_params = {} + query_params[:per_page] = per_page unless per_page.nil? + query_params[:search] = name unless name.nil? + query_params[:token] = token unless token.nil? + + response = client.get(base_path, query_params) + + EmailCampaignsListResponse.new( + data: Array(response[:data]).map { |item| build_entity(item, response_class) }, + pagination: response[:pagination] + ) + end + + # Retrieves a specific email campaign + # @param email_campaign_id [Integer] The email campaign ID + # @return [EmailCampaign] Email campaign object + # @!macro api_errors + def get(email_campaign_id) + base_get(email_campaign_id) + end + + # Creates a new email campaign in the +draft+ state + # @param [Hash] options The parameters to create + # @option options [String] :name Campaign name (required) + # @option options [String] :mailsend_domain_id UUID of the verified sending domain (required) + # @option options [String] :from_display_name Display name shown in the From header + # @option options [String] :from_local_part Local part (before the @) of the From address (required) + # @option options [Hash] :reply_to Reply-To address parts (+display_name+, +local_part+, +domain+) + # @option options [Hash] :template_attributes Template attributes (+subject+ (required), + # +body_html+, +body_text+, +merge_tags+) + # @option options [String] :delivery_mode How the campaign is delivered (+rapid+ or +gradual+) + # @option options [Hash] :delivery_options Delivery throttling options (+emails_per_hour+), + # applies when +delivery_mode+ is +gradual+ + # @option options [Array] :contact_list_ids IDs of contact lists to send to + # @option options [Array] :contact_segment_ids IDs of contact segments to send to + # @return [EmailCampaign] Created email campaign + # @!macro api_errors + # @raise [ArgumentError] If invalid options are provided + def create(options) + base_create(options) + end + + # Updates an existing +draft+ email campaign. Only the provided attributes are changed; + # +template_attributes+ sub-fields are also updated partially, in place. + # @param email_campaign_id [Integer] The email campaign ID + # @param [Hash] options The parameters to update; accepts the same fields as {#create} + # @option options [String] :name Campaign name + # @option options [String] :mailsend_domain_id UUID of the verified sending domain + # @option options [String] :from_display_name Display name shown in the From header + # @option options [String] :from_local_part Local part (before the @) of the From address + # @option options [Hash] :reply_to Reply-To address parts (+display_name+, +local_part+, +domain+) + # @option options [Hash] :template_attributes Template attributes (+subject+, +body_html+, + # +body_text+, +merge_tags+) + # @option options [String] :delivery_mode How the campaign is delivered (+rapid+ or +gradual+) + # @option options [Hash] :delivery_options Delivery throttling options (+emails_per_hour+), + # applies when +delivery_mode+ is +gradual+ + # @option options [Array] :contact_list_ids IDs of contact lists to send to + # @option options [Array] :contact_segment_ids IDs of contact segments to send to + # @return [EmailCampaign] Updated email campaign + # @!macro api_errors + # @raise [ArgumentError] If invalid options are provided + def update(email_campaign_id, options) + base_update(email_campaign_id, options) + end + + # Deletes an email campaign. The campaign must not be in a sending state. + # @param email_campaign_id [Integer] The email campaign ID + # @return [nil] + # @!macro api_errors + def delete(email_campaign_id) + base_delete(email_campaign_id) + end + + # Starts sending a +draft+ campaign immediately + # @param email_campaign_id [Integer] The email campaign ID + # @return [EmailCampaign] The started email campaign + # @!macro api_errors + def start(email_campaign_id) + perform_action(email_campaign_id, :start) + end + + # Schedules a +draft+ campaign to start sending at a future time. + # The time is reported back in +current_state_metadata.scheduled_at+. + # @param email_campaign_id [Integer] The email campaign ID + # @param datetime [String] When to send the campaign (ISO 8601); must be in the future + # and no more than 1 month ahead + # @return [EmailCampaign] The scheduled email campaign + # @!macro api_errors + def schedule(email_campaign_id, datetime) + perform_action(email_campaign_id, :schedule, { datetime: }) + end + + # Cancels a +scheduled+ campaign, returning it to the +draft+ state + # @param email_campaign_id [Integer] The email campaign ID + # @return [EmailCampaign] The cancelled email campaign + # @!macro api_errors + def cancel(email_campaign_id) + perform_action(email_campaign_id, :cancel) + end + + # Terminates a campaign that is currently sending (+started+, +queued+, or +paused+), + # aborting the in-flight send + # @param email_campaign_id [Integer] The email campaign ID + # @return [EmailCampaign] The terminated email campaign + # @!macro api_errors + def terminate(email_campaign_id) + perform_action(email_campaign_id, :terminate) + end + + # Resets a +scheduled+ campaign back to the +draft+ state + # @param email_campaign_id [Integer] The email campaign ID + # @return [EmailCampaign] The reset email campaign + # @!macro api_errors + def reset(email_campaign_id) + perform_action(email_campaign_id, :reset) + end + + # Retrieves aggregated performance statistics for an email campaign. + # By default statistics are aggregated since the campaign was last started. + # @param email_campaign_id [Integer] The email campaign ID + # @param start_date [String, nil] Start of the aggregation window (inclusive), +YYYY-MM-DD+ + # @param end_date [String, nil] End of the aggregation window (inclusive), +YYYY-MM-DD+ + # @return [EmailCampaignStats] Aggregated campaign statistics + # @!macro api_errors + def stats(email_campaign_id, start_date: nil, end_date: nil) + query_params = {} + query_params[:start_date] = start_date unless start_date.nil? + query_params[:end_date] = end_date unless end_date.nil? + + response = client.get("#{base_path}/#{email_campaign_id}/stats", query_params) + build_entity(response[:data], EmailCampaignStats) + end + + private + + def perform_action(email_campaign_id, action, body = nil) + response = client.post("#{base_path}/#{email_campaign_id}/#{action}", body) + handle_response(response) + end + + def base_path + '/api/email_campaigns' + end + + def handle_response(response) + build_entity(response[:data], response_class) + end + end +end diff --git a/spec/mailtrap/email_campaign_spec.rb b/spec/mailtrap/email_campaign_spec.rb new file mode 100644 index 00000000..2d4932b3 --- /dev/null +++ b/spec/mailtrap/email_campaign_spec.rb @@ -0,0 +1,111 @@ +# frozen_string_literal: true + +RSpec.describe Mailtrap::EmailCampaign do + describe '#initialize' do + subject(:email_campaign) { described_class.new(attributes) } + + let(:attributes) do + { + id: 4567, + type: 'ContactsEmailCampaign', + mailsend_domain_id: 'd2313359-acb4-4b87-bce6-f5774f6a1e37', + mailsend_domain_name: 'acme.com', + name: 'Spring Sale', + from_local_part: 'news', + from_display_name: 'Acme Marketing', + reply_to: { display_name: 'Acme Support', local_part: 'support', domain: 'acme.com' }, + current_state: 'scheduled', + current_state_metadata: { scheduled_at: '2026-06-01T09:00:00.000Z' }, + created_at: '2026-05-01T10:15:00.000Z', + updated_at: '2026-05-02T09:00:00.000Z', + last_started_at: nil, + last_started_at_date: nil, + recipient_total_count: 1500, + contact_list_ids: [55, 56], + contact_segment_ids: [12], + delivery_mode: 'gradual', + delivery_options: { emails_per_hour: 1000 }, + template: { + id: 789, + subject: 'Spring is here — 30% off', + merge_tags: ['first_name'], + body_html: 'Hi {{first_name}}!', + body_text: nil + } + } + end + + it 'creates an email campaign with all attributes' do + expect(email_campaign).to have_attributes(attributes) + end + + context 'when optional fields are omitted (e.g. a list item)' do + let(:attributes) do + { + id: 4567, + type: 'ContactsEmailCampaign', + name: 'Spring Sale', + current_state: 'draft' + } + end + + it 'leaves omitted members as nil' do + expect(email_campaign).to have_attributes( + id: 4567, + name: 'Spring Sale', + current_state: 'draft', + contact_list_ids: nil, + contact_segment_ids: nil, + recipient_total_count: nil, + template: nil + ) + end + end + end + + describe Mailtrap::EmailCampaignStats do + subject(:stats) { described_class.new(attributes) } + + let(:attributes) do + { + delivery_count: 1450, + open_count: 820, + click_count: 310, + bounce_count: 30, + unsubscription_count: 12, + sent_count: 1500, + spam_count: 5, + message_count: 1500, + reject_count: 20, + delivery_rate: 0.9667, + open_rate: 0.5655, + click_rate: 0.2138, + bounce_rate: 0.02, + spam_rate: 0.0033, + unsubscription_rate: 0.0083 + } + end + + it 'creates stats with all attributes' do + expect(stats).to have_attributes(attributes) + end + end + + describe Mailtrap::EmailCampaignsListResponse do + subject(:list_response) { described_class.new(attributes) } + + let(:attributes) do + { + data: [Mailtrap::EmailCampaign.new(id: 4567, name: 'Spring Sale')], + pagination: { token: 1, prev_token: nil, next_token: 2 } + } + end + + it 'creates a list response with data and pagination' do + expect(list_response).to have_attributes( + data: [Mailtrap::EmailCampaign.new(id: 4567, name: 'Spring Sale')], + pagination: { token: 1, prev_token: nil, next_token: 2 } + ) + end + end +end diff --git a/spec/mailtrap/email_campaigns_api_spec.rb b/spec/mailtrap/email_campaigns_api_spec.rb new file mode 100644 index 00000000..f2a735ae --- /dev/null +++ b/spec/mailtrap/email_campaigns_api_spec.rb @@ -0,0 +1,389 @@ +# frozen_string_literal: true + +RSpec.describe Mailtrap::EmailCampaignsAPI do + subject(:email_campaigns_api) { described_class.new(Mailtrap::Client.new(api_key: 'correct-api-key')) } + + let(:base_url) { 'https://mailtrap.io/api/email_campaigns' } + let(:campaign_attributes) do + { + 'id' => 4567, + 'type' => 'ContactsEmailCampaign', + 'mailsend_domain_id' => 'd2313359-acb4-4b87-bce6-f5774f6a1e37', + 'mailsend_domain_name' => 'acme.com', + 'name' => 'Spring Sale', + 'from_local_part' => 'news', + 'from_display_name' => 'Acme Marketing', + 'current_state' => 'draft', + 'current_state_metadata' => {}, + 'contact_list_ids' => [55, 56], + 'contact_segment_ids' => [12], + 'delivery_mode' => 'rapid', + 'delivery_options' => { 'emails_per_hour' => nil }, + 'recipient_total_count' => nil, + 'template' => { + 'id' => 789, + 'subject' => 'Spring is here — 30% off', + 'merge_tags' => ['first_name'], + 'body_html' => nil, + 'body_text' => nil + } + } + end + + describe '#list' do + it 'returns a paginated list of EmailCampaign objects' do + stub_request(:get, base_url) + .to_return( + status: 200, + body: { + 'data' => [campaign_attributes], + 'pagination' => { 'token' => 1, 'prev_token' => nil, 'next_token' => nil } + }.to_json, + headers: { 'Content-Type' => 'application/json' } + ) + + response = email_campaigns_api.list + expect(response).to be_a(Mailtrap::EmailCampaignsListResponse) + expect(response.data).to all(be_a(Mailtrap::EmailCampaign)) + expect(response.data.first).to have_attributes( + id: 4567, + name: 'Spring Sale', + mailsend_domain_id: 'd2313359-acb4-4b87-bce6-f5774f6a1e37', + contact_list_ids: [55, 56], + contact_segment_ids: [12], + delivery_mode: 'rapid' + ) + expect(response.pagination).to eq(token: 1, prev_token: nil, next_token: nil) + end + + it 'filters campaigns by name and passes pagination params' do + stub = stub_request(:get, base_url) + .with(query: { search: 'Spring', per_page: '10', token: '2' }) + .to_return( + status: 200, + body: { 'data' => [], 'pagination' => { 'token' => 2 } }.to_json, + headers: { 'Content-Type' => 'application/json' } + ) + + response = email_campaigns_api.list(per_page: 10, name: 'Spring', token: 2) + expect(stub).to have_been_requested + expect(response.data).to eq([]) + end + + it 'raises error when api key is incorrect' do + stub_request(:get, base_url) + .to_return( + status: 401, + body: { 'error' => 'Incorrect API token' }.to_json, + headers: { 'Content-Type' => 'application/json' } + ) + + expect { email_campaigns_api.list }.to raise_error(Mailtrap::AuthorizationError, /Incorrect API token/) + end + end + + describe '#get' do + it 'returns an EmailCampaign object' do + stub_request(:get, "#{base_url}/4567") + .to_return( + status: 200, + body: { 'data' => campaign_attributes }.to_json, + headers: { 'Content-Type' => 'application/json' } + ) + + response = email_campaigns_api.get(4567) + expect(response).to be_a(Mailtrap::EmailCampaign) + expect(response).to have_attributes(id: 4567, name: 'Spring Sale', current_state: 'draft') + end + + it 'raises error when the campaign does not exist' do + stub_request(:get, "#{base_url}/999") + .to_return( + status: 404, + body: { 'error' => 'Not Found' }.to_json, + headers: { 'Content-Type' => 'application/json' } + ) + + expect { email_campaigns_api.get(999) }.to raise_error(Mailtrap::Error, /Not Found/) + end + end + + describe '#create' do + let(:request) do + { + name: 'Spring Sale', + mailsend_domain_id: 'd2313359-acb4-4b87-bce6-f5774f6a1e37', + from_display_name: 'Acme Marketing', + from_local_part: 'news', + reply_to: { display_name: 'Acme Support', local_part: 'support', domain: 'acme.com' }, + template_attributes: { subject: 'Spring is here — 30% off' }, + delivery_mode: 'gradual', + delivery_options: { emails_per_hour: 1000 }, + contact_list_ids: [55, 56], + contact_segment_ids: [12] + } + end + + it 'sends a flat request body and returns the created EmailCampaign' do + stub = stub_request(:post, base_url) + .with(body: request.to_json) + .to_return( + status: 201, + body: { 'data' => campaign_attributes }.to_json, + headers: { 'Content-Type' => 'application/json' } + ) + + response = email_campaigns_api.create(request) + expect(stub).to have_been_requested + expect(response).to be_a(Mailtrap::EmailCampaign) + expect(response).to have_attributes(id: 4567, name: 'Spring Sale', current_state: 'draft') + end + + it 'raises ArgumentError when invalid options are provided' do + expect { email_campaigns_api.create(name: 'Spring Sale', unknown_option: true) } + .to raise_error(ArgumentError, /invalid options are given/) + end + + it 'raises error when validation fails' do + stub_request(:post, base_url) + .to_return( + status: 422, + body: { 'errors' => { 'mailsend_domain_id' => ["can't be blank"] } }.to_json, + headers: { 'Content-Type' => 'application/json' } + ) + + expect { email_campaigns_api.create(name: 'Spring Sale') }.to raise_error(Mailtrap::Error) + end + end + + describe '#update' do + let(:request) do + { + name: 'Spring Sale (updated)', + template_attributes: { subject: 'New subject', body_html: 'Hi!' } + } + end + + it 'sends a flat PATCH request body and returns the updated EmailCampaign' do + stub = stub_request(:patch, "#{base_url}/4567") + .with(body: request.to_json) + .to_return( + status: 200, + body: { 'data' => campaign_attributes.merge('name' => 'Spring Sale (updated)') }.to_json, + headers: { 'Content-Type' => 'application/json' } + ) + + response = email_campaigns_api.update(4567, request) + expect(stub).to have_been_requested + expect(response).to have_attributes(id: 4567, name: 'Spring Sale (updated)') + end + + it 'raises ArgumentError when invalid options are provided' do + expect { email_campaigns_api.update(4567, unknown_option: true) } + .to raise_error(ArgumentError, /invalid options are given/) + end + + it 'raises error when the campaign is not a draft' do + stub_request(:patch, "#{base_url}/4567") + .to_return( + status: 422, + body: { 'errors' => 'Only draft campaigns can be updated' }.to_json, + headers: { 'Content-Type' => 'application/json' } + ) + + expect { email_campaigns_api.update(4567, name: 'New name') }.to raise_error(Mailtrap::Error) + end + end + + describe '#delete' do + it 'deletes the campaign and returns nil' do + stub_request(:delete, "#{base_url}/4567").to_return(status: 204) + + expect(email_campaigns_api.delete(4567)).to be_nil + end + + it 'raises error when the campaign does not exist' do + stub_request(:delete, "#{base_url}/999") + .to_return( + status: 404, + body: { 'error' => 'Not Found' }.to_json, + headers: { 'Content-Type' => 'application/json' } + ) + + expect { email_campaigns_api.delete(999) }.to raise_error(Mailtrap::Error, /Not Found/) + end + end + + describe '#start' do + it 'starts the campaign' do + stub = stub_request(:post, "#{base_url}/4567/start") + .to_return( + status: 200, + body: { 'data' => campaign_attributes.merge('current_state' => 'started') }.to_json, + headers: { 'Content-Type' => 'application/json' } + ) + + response = email_campaigns_api.start(4567) + expect(stub).to have_been_requested + expect(response).to have_attributes(id: 4567, current_state: 'started') + end + + it 'raises error when the campaign is not a draft' do + stub_request(:post, "#{base_url}/4567/start") + .to_return( + status: 422, + body: { 'errors' => "Cannot transition from 'started' to 'started'" }.to_json, + headers: { 'Content-Type' => 'application/json' } + ) + + expect { email_campaigns_api.start(4567) }.to raise_error(Mailtrap::Error, /Cannot transition/) + end + end + + describe '#schedule' do + it 'sends the datetime and returns the scheduled campaign' do + stub = stub_request(:post, "#{base_url}/4567/schedule") + .with(body: { datetime: '2026-06-01T09:00:00.000Z' }.to_json) + .to_return( + status: 200, + body: { + 'data' => campaign_attributes.merge( + 'current_state' => 'scheduled', + 'current_state_metadata' => { 'scheduled_at' => '2026-06-01T09:00:00.000Z' } + ) + }.to_json, + headers: { 'Content-Type' => 'application/json' } + ) + + response = email_campaigns_api.schedule(4567, '2026-06-01T09:00:00.000Z') + expect(stub).to have_been_requested + expect(response).to have_attributes( + current_state: 'scheduled', + current_state_metadata: { scheduled_at: '2026-06-01T09:00:00.000Z' } + ) + end + + it 'raises error when sending validation fails' do + stub_request(:post, "#{base_url}/4567/schedule") + .with(body: { datetime: '2026-06-01T09:00:00.000Z' }.to_json) + .to_return( + status: 422, + body: { 'errors' => ["Campaign design can't be blank"] }.to_json, + headers: { 'Content-Type' => 'application/json' } + ) + + expect { email_campaigns_api.schedule(4567, '2026-06-01T09:00:00.000Z') } + .to raise_error(Mailtrap::Error, /design can't be blank/) + end + end + + describe '#cancel' do + it 'cancels the scheduled campaign' do + stub_request(:post, "#{base_url}/4567/cancel") + .to_return( + status: 200, + body: { 'data' => campaign_attributes }.to_json, + headers: { 'Content-Type' => 'application/json' } + ) + + expect(email_campaigns_api.cancel(4567)).to have_attributes(id: 4567, current_state: 'draft') + end + + it 'raises error when the campaign is not scheduled' do + stub_request(:post, "#{base_url}/4567/cancel") + .to_return( + status: 422, + body: { 'errors' => 'Campaign is not scheduled' }.to_json, + headers: { 'Content-Type' => 'application/json' } + ) + + expect { email_campaigns_api.cancel(4567) }.to raise_error(Mailtrap::Error, /not scheduled/) + end + end + + describe '#terminate' do + it 'terminates the sending campaign' do + stub_request(:post, "#{base_url}/4567/terminate") + .to_return( + status: 200, + body: { 'data' => campaign_attributes.merge('current_state' => 'terminating') }.to_json, + headers: { 'Content-Type' => 'application/json' } + ) + + expect(email_campaigns_api.terminate(4567)).to have_attributes(current_state: 'terminating') + end + end + + describe '#reset' do + it 'resets the scheduled campaign back to draft' do + stub_request(:post, "#{base_url}/4567/reset") + .to_return( + status: 200, + body: { 'data' => campaign_attributes }.to_json, + headers: { 'Content-Type' => 'application/json' } + ) + + expect(email_campaigns_api.reset(4567)).to have_attributes(current_state: 'draft') + end + end + + describe '#stats' do + let(:stats_attributes) do + { + 'delivery_count' => 1450, + 'open_count' => 820, + 'click_count' => 310, + 'bounce_count' => 30, + 'unsubscription_count' => 12, + 'sent_count' => 1500, + 'spam_count' => 5, + 'message_count' => 1500, + 'reject_count' => 20, + 'delivery_rate' => 0.9667, + 'open_rate' => 0.5655, + 'click_rate' => 0.2138, + 'bounce_rate' => 0.02, + 'spam_rate' => 0.0033, + 'unsubscription_rate' => 0.0083 + } + end + + it 'returns an EmailCampaignStats object' do + stub_request(:get, "#{base_url}/4567/stats") + .to_return( + status: 200, + body: { 'data' => stats_attributes }.to_json, + headers: { 'Content-Type' => 'application/json' } + ) + + response = email_campaigns_api.stats(4567) + expect(response).to be_a(Mailtrap::EmailCampaignStats) + expect(response).to have_attributes(delivery_count: 1450, delivery_rate: 0.9667) + end + + it 'passes the aggregation window params' do + stub = stub_request(:get, "#{base_url}/4567/stats") + .with(query: { start_date: '2026-05-01', end_date: '2026-05-31' }) + .to_return( + status: 200, + body: { 'data' => stats_attributes }.to_json, + headers: { 'Content-Type' => 'application/json' } + ) + + response = email_campaigns_api.stats(4567, start_date: '2026-05-01', end_date: '2026-05-31') + expect(stub).to have_been_requested + expect(response).to have_attributes(sent_count: 1500) + end + + it 'raises error when the campaign does not exist' do + stub_request(:get, "#{base_url}/999/stats") + .to_return( + status: 404, + body: { 'error' => 'Not Found' }.to_json, + headers: { 'Content-Type' => 'application/json' } + ) + + expect { email_campaigns_api.stats(999) }.to raise_error(Mailtrap::Error, /Not Found/) + end + end +end