diff --git a/examples/contacts_api.rb b/examples/contacts_api.rb index 6e48aba..598cb11 100644 --- a/examples/contacts_api.rb +++ b/examples/contacts_api.rb @@ -4,6 +4,7 @@ contact_lists = Mailtrap::ContactListsAPI.new 3229, client contacts = Mailtrap::ContactsAPI.new 3229, client contact_fields = Mailtrap::ContactFieldsAPI.new 3229, client +contact_imports = Mailtrap::ContactImportsAPI.new 3229, client # Set your API credentials as environment variables # export MAILTRAP_API_KEY='your-api-key' @@ -12,38 +13,42 @@ # contact_lists = Mailtrap::ContactListsAPI.new # contacts = Mailtrap::ContactsAPI.new # contact_fields = Mailtrap::ContactFieldsAPI.new +# contact_imports = Mailtrap::ContactImportsAPI.new # Create new contact list list = contact_lists.create(name: 'Test List') -# => ContactList.new(id: 1, name: 'Test List') +# => # # Get all contact lists contact_lists.list -# => [ContactList.new(id: 1, name: 'Test List')] +# => [#] # Update contact list contact_lists.update(list.id, name: 'Test List Updated') -# => ContactList.new(id: 1, name: 'Test List Updated') +# => # # Get contact list list = contact_lists.get(list.id) -# => ContactList.new(id: 1, name: 'Test List Updated') +# => # + +# Delete contact list +contact_lists.delete(list.id) # Create new contact field field = contact_fields.create(name: 'Nickname', data_type: 'text', merge_tag: 'nickname') -# => ContactField.new(id: 1, name: 'Nickname', data_type: 'text', merge_tag: 'nickname') +# => # # Get all contact fields contact_fields.list -# => [ContactField.new(id: 1, name: 'Nickname', data_type: 'text', merge_tag: 'nickname')] +# => [#] # Update contact field contact_fields.update(field.id, name: 'Nickname 2', merge_tag: 'nickname') -# => ContactField.new(id: 1, name: 'Nickname 2', data_type: 'text', merge_tag: 'nickname') +# => # # Get contact field field = contact_fields.get(field.id) -# => ContactField.new(id: 1, name: 'Nickname 2', data_type: 'text', merge_tag: 'nickname') +# => # # Create new contact with all possible fields contact = contacts.create( @@ -51,28 +56,26 @@ fields: { field.merge_tag => 'John Doe' }, list_ids: [list.id] ) -# => Contact.new( -# id: 1, -# email: 'test@example.com', -# fields: { 'nickname' => 'John Doe' }, -# list_ids: [1], -# status: 'subscribed', -# created_at: 1721212345, -# updated_at: 1721212345 -# ) +# => # "John Doe" }, +# list_ids=[1], +# status="subscribed", +# created_at=1721212345, +# updated_at=1721212345> contact.newly_created? # => true # Get contact contact = contacts.get(contact.id) -# => Contact.new( -# id: 1, -# email: 'test@example.com', -# fields: { 'nickname' => 'John Doe' }, -# list_ids: [1], -# status: 'subscribed', -# created_at: 1721212345, -# updated_at: 1721212345 -# ) +# => # "John Doe" }, +# list_ids=[1], +# status="subscribed", +# created_at=1721212345, +# updated_at=1721212345> # Update contact using id updated_contact = contacts.upsert( @@ -80,15 +83,14 @@ email: 'test2@example.com', fields: { field.merge_tag => 'Jane Doe' } ) -# => Contact.new( -# id: 1, -# email: 'test2@example.com', -# fields: { 'nickname' => 'Jane Doe' }, -# list_ids: [1], -# status: 'subscribed', -# created_at: 1721212345, -# updated_at: 1721212350 -# ) +# => # "Jane Doe" }, +# list_ids=[1], +# status="subscribed", +# created_at=1721212345, +# updated_at=1721212350> updated_contact.newly_created? # => false # Update contact using email @@ -97,46 +99,79 @@ email: 'test3@example.com', fields: { field.merge_tag => 'Jane Doe' } ) -# => Contact.new( -# id: 1, -# email: 'test3@example.com', -# fields: { 'nickname' => 'Jane Doe' }, -# list_ids: [1], -# status: 'subscribed', -# created_at: 1721212345, -# updated_at: 1721212355 -# ) +# => # "Jane Doe" }, +# list_ids=[1], +# status="subscribed", +# created_at=1721212345, +# updated_at=1721212355> updated_contact.newly_created? # => false # Remove contact from lists contacts.remove_from_lists(contact.id, [list.id]) -# => Contact.new( -# id: 1, -# email: 'test3@example.com', -# fields: { 'nickname' => 'Jane Doe' }, -# list_ids: [], -# status: 'subscribed', -# created_at: 1721212345, -# updated_at: 1721212360 -# ) +# => # "Jane Doe" }, +# list_ids=[], +# status="subscribed", +# created_at=1721212345, +# updated_at=1721212360> # Add contact to lists contacts.add_to_lists(contact.id, [list.id]) -# => Contact.new( -# id: 1, -# email: 'test3@example.com', -# fields: { 'nickname' => 'Jane Doe' }, -# list_ids: [1], -# status: 'subscribed', -# created_at: 1721212345, -# updated_at: 1721212365 -# ) +# => # "Jane Doe" }, +# list_ids=[1], +# status="subscribed", +# created_at=1721212345, +# updated_at=1721212365> # Delete contact contacts.delete(contact.id) -# Delete contact list -contact_lists.delete(list.id) - # Delete contact field contact_fields.delete(field.id) + +# Create a new contact import using ContactsImportRequest builder +import_request = Mailtrap::ContactsImportRequest.new.tap do |req| + req.upsert(email: 'john.doe@example.com', fields: { first_name: 'John' }) + .add_to_lists(email: 'john.doe@example.com', list_ids: [1]) + .remove_from_lists(email: 'jane.smith@example.com', list_ids: [2]) + + req.upsert(email: 'jane.smith@example.com', fields: { first_name: 'Jane' }) + .add_to_lists(email: 'jane.smith@example.com', list_ids: [1]) + .remove_from_lists(email: 'jane.smith@example.com', list_ids: [2]) +end + +# Execute the import +contact_import = contact_imports.create(import_request) +# => # + +# Wait for the import to complete (if needed) + +# Get the import status +contact_imports.get(contact_import.id) +# => # + +# Import using plain hash +contact_imports.create( + [ + { email: 'john@example.com', fields: { first_name: 'John' }, list_ids_included: [1], list_ids_excluded: [2] }, + { email: 'jane@example.com', fields: { first_name: 'Jane' }, list_ids_included: [1], list_ids_excluded: [2] } + ] +) diff --git a/lib/mailtrap.rb b/lib/mailtrap.rb index 33e4dad..9d3b856 100644 --- a/lib/mailtrap.rb +++ b/lib/mailtrap.rb @@ -8,6 +8,7 @@ require_relative 'mailtrap/contacts_api' require_relative 'mailtrap/contact_lists_api' require_relative 'mailtrap/contact_fields_api' +require_relative 'mailtrap/contact_imports_api' module Mailtrap # @!macro api_errors diff --git a/lib/mailtrap/contact_import.rb b/lib/mailtrap/contact_import.rb new file mode 100644 index 0000000..df2a9b9 --- /dev/null +++ b/lib/mailtrap/contact_import.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module Mailtrap + # Data Transfer Object for Contact Import + # @attr_reader id [Integer] The contact import ID + # @attr_reader status [String] The status of the import (created, started, finished, failed) + # @attr_reader created_contacts_count [Integer, nil] Number of contacts created in this import + # @attr_reader updated_contacts_count [Integer, nil] Number of contacts updated in this import + # @attr_reader contacts_over_limit_count [Integer, nil] Number of contacts over the allowed limit + ContactImport = Struct.new( + :id, + :status, + :created_contacts_count, + :updated_contacts_count, + :contacts_over_limit_count, + keyword_init: true + ) do + # @return [Hash] The contact attributes as a hash + def to_h + super.compact + end + end +end diff --git a/lib/mailtrap/contact_imports_api.rb b/lib/mailtrap/contact_imports_api.rb new file mode 100644 index 0000000..3b6a754 --- /dev/null +++ b/lib/mailtrap/contact_imports_api.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +require_relative 'contact_import' +require_relative 'contacts_import_request' + +module Mailtrap + class ContactImportsAPI + include BaseAPI + + self.supported_options = %i[email fields list_ids_included list_ids_excluded] + + self.response_class = ContactImport + + # Retrieves a specific contact import + # @param import_id [Integer] The contact import identifier + # @return [ContactImport] Contact import object + # @!macro api_errors + def get(import_id) + base_get(import_id) + end + + # Create contacts import + # + # @example Using Mailtrap::ContactsImportRequest + # import_request = Mailtrap::ContactsImportRequest.new.tap do |req| + # req.upsert(email: 'jane@example.com', fields: { first_name: 'Jane' }) + # .add_to_lists(email: 'jane@example.com', list_ids: [1]) + # .remove_from_lists(email: 'jane@example.com', list_ids: [2]) + # req.upsert(email: 'john@example.com', fields: { first_name: 'John' }) + # .add_to_lists(email: 'john@example.com', list_ids: [1]) + # .remove_from_lists(email: 'john@example.com', list_ids: [2]) + # end + # contact_imports.create(import_request) + # + # @example Using plain hash + # contact_imports.create([ + # {email: 'john@example.com', fields: { first_name: 'John' }, list_ids_included: [1], list_ids_excluded: [2]}, + # {email: 'jane@example.com', fields: { first_name: 'Jane' }, list_ids_included: [1], list_ids_excluded: [2]} + # ]) + # + # @param contacts [Mailtrap::ContactsImportRequest, Array] The contacts import request + # + # @return [ContactImport] Created contact import object + # @!macro api_errors + # @raise [ArgumentError] If invalid options are provided + def create(contacts) + contact_data = contacts.to_a.each do |contact| + validate_options!(contact, supported_options) + end + response = client.post(base_path, contacts: contact_data) + handle_response(response) + end + alias start create + + private + + def base_path + "/api/accounts/#{account_id}/contacts/imports" + end + end +end diff --git a/lib/mailtrap/contacts_import_request.rb b/lib/mailtrap/contacts_import_request.rb new file mode 100644 index 0000000..731a507 --- /dev/null +++ b/lib/mailtrap/contacts_import_request.rb @@ -0,0 +1,67 @@ +# frozen_string_literal: true + +module Mailtrap + # A builder class for creating contact import requests + # Allows you to build a collection of contacts with their associated fields and list memberships + class ContactsImportRequest + def initialize + @data = Hash.new do |h, k| + h[k] = { email: k, fields: {}, list_ids_included: [], list_ids_excluded: [] } + end + end + + # Creates or updates a contact with the provided email and fields + # @param email [String] The contact's email address + # @param fields [Hash] Contact fields in the format: field_merge_tag => String, Integer, Float, Boolean, or + # ISO-8601 date string (yyyy-mm-dd) + # @return [ContactsImportRequest] Returns self for method chaining + def upsert(email:, fields: {}) + validate_email!(email) + + @data[email][:fields].merge!(fields) + + self + end + + # Adds a contact to the specified lists + # @param email [String] The contact's email address + # @param list_ids [Array] Array of list IDs to add the contact to + # @return [ContactsImportRequest] Returns self for method chaining + def add_to_lists(email:, list_ids:) + validate_email!(email) + + append_list_ids email:, list_ids:, key: :list_ids_included + + self + end + + # Removes a contact from the specified lists + # @param email [String] The contact's email address + # @param list_ids [Array] Array of list IDs to remove the contact from + # @return [ContactsImportRequest] Returns self for method chaining + def remove_from_lists(email:, list_ids:) + validate_email!(email) + + append_list_ids email:, list_ids:, key: :list_ids_excluded + + self + end + + # @return [Array] Array of contact objects ready for import + def to_a + @data.values + end + + private + + def validate_email!(email) + raise ArgumentError, 'email must be present' if email.nil? || email.empty? + end + + def append_list_ids(email:, list_ids:, key:) + raise ArgumentError, 'list_ids must be present' if list_ids.empty? + + @data[email][key] |= list_ids + end + end +end diff --git a/spec/mailtrap/contact_imports_api_spec.rb b/spec/mailtrap/contact_imports_api_spec.rb new file mode 100644 index 0000000..b96fb79 --- /dev/null +++ b/spec/mailtrap/contact_imports_api_spec.rb @@ -0,0 +1,96 @@ +# frozen_string_literal: true + +RSpec.describe Mailtrap::ContactImportsAPI do + let(:imports_api) { described_class.new(123, Mailtrap::Client.new(api_key: 'correct-api-key')) } + + describe '#get' do + it 'returns a specific contact import' do + expected_response = { + id: 1, + status: 'finished', + created_contacts_count: 10, + updated_contacts_count: 2, + contacts_over_limit_count: 0 + } + stub_request(:get, %r{/api/accounts/123/contacts/imports/1}) + .to_return( + status: 200, + body: expected_response.to_json, + headers: { 'Content-Type' => 'application/json' } + ) + + response = imports_api.get(1) + expect(response).to have_attributes(expected_response) + end + + it 'raises error when contact import not found' do + stub_request(:get, %r{/api/accounts/123/contacts/imports/1}) + .to_return( + status: 404, + body: { 'error' => 'Not Found' }.to_json, + headers: { 'Content-Type' => 'application/json' } + ) + + expect { imports_api.get(1) }.to raise_error(Mailtrap::Error) + end + end + + describe '#create' do + it 'creates a new contact import' do + request = Mailtrap::ContactsImportRequest.new.tap do |req| + req.upsert(email: 'j.doe@example.com', fields: { first_name: 'John' }) + req.add_to_lists(email: 'j.doe@example.com', list_ids: [1, 2]) + req.remove_from_lists(email: 'j.doe@example.com', list_ids: [3]) + end + + stub_request(:post, %r{/api/accounts/123/contacts/imports}) + .with(body: { contacts: request.to_a }.to_json) + .to_return( + status: 200, + body: { id: 1, status: 'started' }.to_json, + headers: { 'Content-Type' => 'application/json' } + ) + + response = imports_api.create(request) + expect(response).to have_attributes( + id: 1, + status: 'started', + created_contacts_count: nil, + updated_contacts_count: nil, + contacts_over_limit_count: nil + ) + + response = imports_api.create(request.to_a) + expect(response).to have_attributes( + id: 1, + status: 'started', + created_contacts_count: nil, + updated_contacts_count: nil, + contacts_over_limit_count: nil + ) + end + + it 'validates contacts' do + expect { imports_api.create([{ email: 'john@example.com', foo: 1 }]) }.to raise_error(ArgumentError) + end + + it 'handles api errors' do + stub_request(:post, %r{/api/accounts/123/contacts/imports}) + .with(body: { contacts: [{ email: 'john@example' }] }.to_json) + .to_return( + status: 422, + body: { + 'errors' => { + 'email' => 'john@example', + 'errors' => { + 'email' => ['is invalid'] + } + } + }.to_json, + headers: { 'Content-Type' => 'application/json' } + ) + + expect { imports_api.create([{ email: 'john@example' }]) }.to raise_error(Mailtrap::Error) + end + end +end diff --git a/spec/mailtrap/contact_spec.rb b/spec/mailtrap/contact_spec.rb index 329be08..34c6c1b 100644 --- a/spec/mailtrap/contact_spec.rb +++ b/spec/mailtrap/contact_spec.rb @@ -13,30 +13,24 @@ list_ids: [1, 2], status: 'subscribed', created_at: 1_700_000_000, - updated_at: 1_700_000_100, - action: + updated_at: 1_700_000_100 } end - let(:action) { 'created' } describe '#newly_created?' do + it { is_expected.to be_newly_created } + context "when action is 'created'" do - let(:action) { 'created' } + let(:attributes) { super().merge(action: 'created') } it { is_expected.to be_newly_created } end context "when action is 'updated'" do - let(:action) { 'updated' } + let(:attributes) { super().merge(action: 'updated') } it { is_expected.not_to be_newly_created } end - - context 'when action is nil' do - let(:action) { nil } - - it { is_expected.to be_newly_created } - end end describe '#to_h' do diff --git a/spec/mailtrap/contacts_import_request_spec.rb b/spec/mailtrap/contacts_import_request_spec.rb new file mode 100644 index 0000000..37b36cb --- /dev/null +++ b/spec/mailtrap/contacts_import_request_spec.rb @@ -0,0 +1,106 @@ +# frozen_string_literal: true + +RSpec.describe Mailtrap::ContactsImportRequest do + describe '#upsert' do + it 'validates email' do + expect { described_class.new.upsert(email: nil) }.to raise_error(ArgumentError) + expect { described_class.new.upsert(email: '') }.to raise_error(ArgumentError) + end + + it 'adds contact to the request' do + request = described_class.new.tap do |req| + req.upsert(email: 'one@example.com') + .upsert(email: 'two@example.com', fields: { first_name: 'John', last_name: 'Doe' }) + .upsert(email: 'three@example.com', fields: { first_name: 'Jack' }) + .upsert(email: 'three@example.com', fields: { first_name: 'Joe', last_name: 'Blow', age: 33 }) + end + expect(request.to_a).to contain_exactly( + hash_including(email: 'one@example.com', fields: {}), + hash_including(email: 'two@example.com', fields: { first_name: 'John', last_name: 'Doe' }), + hash_including(email: 'three@example.com', fields: { first_name: 'Joe', last_name: 'Blow', age: 33 }) + ) + end + end + + describe '#add_to_lists' do + it 'validates email' do + expect { described_class.new.add_to_lists(email: nil, list_ids: [1]) }.to raise_error(ArgumentError) + expect { described_class.new.add_to_lists(email: '', list_ids: [1]) }.to raise_error(ArgumentError) + end + + it 'does not allow empty list' do + expect { described_class.new.add_to_lists(email: 'one@example.com', list_ids: []) }.to raise_error(ArgumentError) + end + + it 'adds contact to the lists' do + request = described_class.new.tap do |req| + req.add_to_lists(email: 'one@example.com', list_ids: [1]) + .add_to_lists(email: 'two@example.com', list_ids: [1]) + .add_to_lists(email: 'two@example.com', list_ids: [2]) + .add_to_lists(email: 'three@example.com', list_ids: [1]) + .add_to_lists(email: 'three@example.com', list_ids: [1, 2]) + end + expect(request.to_a).to contain_exactly( + hash_including(email: 'one@example.com', list_ids_included: [1]), + hash_including(email: 'two@example.com', list_ids_included: [1, 2]), + hash_including(email: 'three@example.com', list_ids_included: [1, 2]) + ) + end + end + + describe '#remove_from_lists' do + it 'validates email' do + expect { described_class.new.remove_from_lists(email: nil, list_ids: [1]) }.to raise_error(ArgumentError) + expect { described_class.new.remove_from_lists(email: '', list_ids: [1]) }.to raise_error(ArgumentError) + end + + it 'does not allow empty list' do + expect do + described_class.new.remove_from_lists(email: 'one@example.com', list_ids: []) + end.to raise_error(ArgumentError) + end + + it 'adds contact to the lists' do + request = described_class.new.tap do |req| + req.remove_from_lists(email: 'one@example.com', list_ids: [1]) + .remove_from_lists(email: 'two@example.com', list_ids: [1]) + .remove_from_lists(email: 'two@example.com', list_ids: [2]) + .remove_from_lists(email: 'three@example.com', list_ids: [1]) + .remove_from_lists(email: 'three@example.com', list_ids: [1, 2]) + end + expect(request.to_a).to contain_exactly( + hash_including(email: 'one@example.com', list_ids_excluded: [1]), + hash_including(email: 'two@example.com', list_ids_excluded: [1, 2]), + hash_including(email: 'three@example.com', list_ids_excluded: [1, 2]) + ) + end + end + + describe '#to_a' do + it 'returns json array' do + request = described_class.new + expect(request.to_a).to eq([]) + + request.upsert(email: 'one@example.com', fields: { first_name: 'John' }) + expect(request.to_a).to contain_exactly( + { email: 'one@example.com', fields: { first_name: 'John' }, list_ids_included: [], list_ids_excluded: [] } + ) + end + end + + it 'supports multiple operations on one contact' do + request = described_class.new.tap do |req| + req.upsert(email: 'one@example.com', fields: { first_name: 'John' }) + .add_to_lists(email: 'one@example.com', list_ids: [1]) + .remove_from_lists(email: 'one@example.com', list_ids: [2]) + + req.remove_from_lists(email: 'two@example.com', list_ids: [12]) + .add_to_lists(email: 'two@example.com', list_ids: [11]) + .upsert(email: 'two@example.com', fields: { first_name: 'Jack' }) + end + expect(request.to_a).to contain_exactly( + { email: 'one@example.com', fields: { first_name: 'John' }, list_ids_included: [1], list_ids_excluded: [2] }, + { email: 'two@example.com', fields: { first_name: 'Jack' }, list_ids_included: [11], list_ids_excluded: [12] } + ) + end +end