Skip to content

Contacts API #49

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 4 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions examples/contacts_api.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
require 'mailtrap'

client = Mailtrap::Client.new(api_key: 'your-api-key')
contact_list = Mailtrap::ContactListsAPI.new 3229, client
contacts = Mailtrap::ContactsAPI.new 3229, client

# Set your API credentials as environment variables
# export MAILTRAP_API_KEY='your-api-key'
# export MAILTRAP_ACCOUNT_ID=your-account-id
#
# contact_list = Mailtrap::ContactListsAPI.new
# contacts = Mailtrap::ContactsAPI.new

# Create new contact list
list = contact_list.create(name: 'Test List')

# Get all contact lists
contact_list.list

# Update contact list
contact_list.update(list.id, name: 'Test List Updated')

# Get contact list
list = contact_list.get(list.id)

# Create new contact
contact = contacts.create(email: '[email protected]', fields: { first_name: 'John Doe' }, list_ids: [list.id])

# Get contact
contact = contacts.get(contact.id)

# Update contact using id
updated_contact = contacts.update(contact.id, email: '[email protected]', fields: { first_name: 'Jane Doe' },
list_ids_excluded: [list.id])

# Update contact using email
contacts.update(updated_contact.data.email, email: '[email protected]', fields: { first_name: 'Jane Doe' },
list_ids_included: [list.id])

# Delete contact
contacts.delete(contact.id)

# Delete contact list
contact_list.delete(list.id)
3 changes: 3 additions & 0 deletions lib/mailtrap.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,11 @@
require_relative 'mailtrap/action_mailer' if defined? ActionMailer
require_relative 'mailtrap/mail'
require_relative 'mailtrap/errors'
require_relative 'mailtrap/base_api'
require_relative 'mailtrap/version'
require_relative 'mailtrap/email_templates_api'
require_relative 'mailtrap/contacts_api'
require_relative 'mailtrap/contact_lists_api'

module Mailtrap
# @!macro api_errors
Expand Down
30 changes: 30 additions & 0 deletions lib/mailtrap/base_api.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# frozen_string_literal: true

module Mailtrap
class BaseAPI
Copy link
Contributor

@i7an i7an Jul 7, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This class has a bunch of helper methods. I wonder if it should be a mixin instead.
As an alternative, you could add methods to the base class, making inheritance more meaningful:

def delete(id)
  client.delete("#{base_path}/#{id}")
end

See also the abstract yard annotation tag.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wouldn't go with mixin, cause main goal was to share #initialize method, and having two entities(base class and mixin) for 3 method seem over-engineered to me.

Copy link
Contributor Author

@sarco3t sarco3t Jul 7, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

also I can advice to have base implementations for all methods here get, create, etc..
and to make that possible I would introduce

  def create(options)
    validate_options!(options, supported_options)

    response = client.post(base_path, options)
    build_entity(response, response_entity)
  end
 
 private
 
  def response_entity
    raise NotImplementedError
  end

  def supported_options
    raise NotImplementedError
  end

and it will be implemented like

class ContactsApi  < BaseApi
  ...
  def supported_options
    SUPPORTED_OPTIONS
  end
  ...
 end

Copy link
Contributor

@i7an i7an Jul 7, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah that what I meant. Though keep in mind that ruby is not java, thus you don't need to implement methods with raise NotImplementedError. Use abstract annotation instead.
Through there is one problem. Not all APIs support all CRUD methods. For example, there is no list in the Contacts API. In this case overriding with raise might be relevant. If you choose to go this route please mind respond_to?.
As a side note, you do can put initialize in a mixin.
UPD NotImplementedError is commonly misused. But this is so common that you can say its the standard.

attr_reader :account_id, :client

# @param account_id [Integer] The account ID
# @param client [Mailtrap::Client] The client instance
# @raise [ArgumentError] If account_id is nil
def initialize(account_id = ENV.fetch('MAILTRAP_ACCOUNT_ID'), client = Mailtrap::Client.new)
raise ArgumentError, 'account_id is required' if account_id.nil?

@account_id = account_id
@client = client
end

private

def validate_options!(options, supported_options)
invalid_options = options.keys - supported_options
return if invalid_options.empty?

raise ArgumentError, "invalid options are given: #{invalid_options}, supported_options: #{supported_options}"
end

def build_entity(options, response_class)
response_class.new(options.slice(*response_class.members))
end
end
end
44 changes: 44 additions & 0 deletions lib/mailtrap/contact.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# frozen_string_literal: true

module Mailtrap
# Data Transfer Object for Contact
# @see https://api-docs.mailtrap.io/docs/mailtrap-api-docs/220a54e31e5ca-contact
# @attr_reader id [String] The contact ID
# @attr_reader email [String] The contact's email address
# @attr_reader fields [Hash] Object of fields with merge tags
# @attr_reader list_ids [Array<Integer>] Array of list IDs
# @attr_reader status [String] The contact status (subscribed/unsubscribed)
# @attr_reader created_at [Integer] The creation timestamp
# @attr_reader updated_at [Integer] The last update timestamp
Contact = Struct.new(
Copy link
Contributor

@i7an i7an Jun 30, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@IgorDobryn if the only motivation for resource classes it to achieve strictness, then we can do this:

contact_hash = {id: 1}
contact_hash.default_proc = proc { |_, k| raise "unknown key '#{k}'" }

Thoughts?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It can work, but rather unexpected. So, I'd prefer to keep separate class

:id,
:email,
:fields,
:list_ids,
:status,
:created_at,
:updated_at,
keyword_init: true
) do
# @return [Hash] The contact attributes as a hash
def to_h
super.compact
end
end

# Data Transfer Object for Contact Update Response
# @see https://api-docs.mailtrap.io/docs/mailtrap-api-docs/16eab4fff9740-contact-update-response
# @attr_reader action [String] The performed action (created/updated)
# @attr_reader data [Contact, Hash] The contact data
ContactUpdateResponse = Struct.new(:action, :data, keyword_init: true) do
def initialize(*)
super
self.data = Contact.new(data) if data.is_a?(Hash)
end

# @return [Hash] The response attributes as a hash
def to_h
super.compact
end
end
end
14 changes: 14 additions & 0 deletions lib/mailtrap/contact_list.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# frozen_string_literal: true

module Mailtrap
# Data Transfer Object for Contact List
# @see https://api-docs.mailtrap.io/docs/mailtrap-api-docs/6ec7a37234af2-contact-list
# @attr_reader id [Integer] The contact list ID
# @attr_reader name [String] The name of the contact list
ContactList = Struct.new(:id, :name, keyword_init: true) do
# @return [Hash] The contact list attributes as a hash
def to_h
super.compact
end
end
end
70 changes: 70 additions & 0 deletions lib/mailtrap/contact_lists_api.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
# frozen_string_literal: true

require_relative 'contact_list'

module Mailtrap
class ContactListsAPI < BaseAPI
Copy link
Contributor

@i7an i7an Jul 7, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do you plan to add Contact Fields API support?

SUPPORTED_OPTIONS = %i[name].freeze
private_constant :SUPPORTED_OPTIONS

# Retrieves a specific contact list
# @param list_id [Integer] The contact list identifier
# @return [ContactList] Contact list object
# @!macro api_errors
def get(list_id)
response = client.get("#{base_path}/#{list_id}")
build_entity(response, ContactList)
end

# Creates a new contact list
# @param [Hash] options The parameters to create
# @option options [String] :name The contact list name
# @return [ContactList] Created contact list object
# @!macro api_errors
# @raise [ArgumentError] If invalid options are provided
def create(options)
validate_options!(options, SUPPORTED_OPTIONS)

response = client.post(base_path, options)
build_entity(response, ContactList)
end

# Updates an existing contact list
# @param list_id [Integer] The contact list ID
# @param [Hash] options The parameters to update
# @option options [String] :name The contact list name
# @return [ContactList] Updated contact list object
# @!macro api_errors
# @raise [ArgumentError] If invalid options are provided
def update(list_id, options)
validate_options!(options, SUPPORTED_OPTIONS)

response = client.patch(
"#{base_path}/#{list_id}", options
)
build_entity(response, ContactList)
end

# Deletes a contact list
# @param list_id [Integer] The contact list ID
# @return nil
# @!macro api_errors
def delete(list_id)
client.delete("#{base_path}/#{list_id}")
end

# Lists all contact lists for the account
# @return [Array<ContactList>] Array of contact list objects
# @!macro api_errors
def list
response = client.get(base_path)
response.map { |list| build_entity(list, ContactList) }
end

private

def base_path
"/api/accounts/#{account_id}/contacts/lists"
end
end
end
71 changes: 71 additions & 0 deletions lib/mailtrap/contacts_api.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
# frozen_string_literal: true

require_relative 'contact'
require 'ostruct'

module Mailtrap
class ContactsAPI < BaseAPI
CREATE_SUPPORTED_OPTIONS = %i[email fields list_ids].freeze
UPDATE_SUPPORTED_OPTIONS = %i[email fields list_ids_included list_ids_excluded unsubscribed].freeze
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe list_ids_included and list_ids_excluded should not be available as options. Instead there should be dedicated methods to add or remove a contact to lists:

  • add_to_lists(contact_id, contact_list_ids)
  • remove_from_lists(contact_id, contact_list_ids)

private_constant :CREATE_SUPPORTED_OPTIONS, :UPDATE_SUPPORTED_OPTIONS

# Retrieves a specific contact
# @param contact_id [String] The contact identifier, which can be either a UUID or an email address
# @return [Contact] Contact object
# @!macro api_errors
def get(contact_id)
response = client.get("#{base_path}/#{contact_id}")
build_entity(response[:data], Contact)
end

# Creates a new contact
# @param [Hash] options The parameters to create
# @option options [String] :email The contact's email address
# @option options [Hash] :fields The contact's fields
# @option options [Array<Integer>] :list_ids The contact's list IDs
# @return [Contact] Created contact object
# @!macro api_errors
# @raise [ArgumentError] If invalid options are provided
def create(options)
validate_options!(options, CREATE_SUPPORTED_OPTIONS)

response = client.post(base_path, { contact: options })
build_entity(response[:data], Contact)
end

# Updates an existing contact
# @param contact_id [String] The contact ID or email address
# @param [Hash] options The parameters to update
# @option options [String] :email The contact's email address
# @option options [Hash] :fields The contact's fields
# @option options [Array<Integer>] :list_ids_included The contact's list IDs to include
# @option options [Array<Integer>] :list_ids_excluded The contact's list IDs to exclude
# @option options [Boolean] :unsubscribed Whether to unsubscribe the contact
# @return [ContactUpdateResponse] Updated contact object
# @!macro api_errors
# @raise [ArgumentError] If invalid options are provided
def update(contact_id, options)
validate_options!(options, UPDATE_SUPPORTED_OPTIONS)

response = client.patch(
"#{base_path}/#{contact_id}",
{ contact: options }
)
build_entity(response, ContactUpdateResponse)
end

# Deletes a contact
# @param contact_id [String] The contact ID
# @return nil
# @!macro api_errors
def delete(contact_id)
client.delete("#{base_path}/#{contact_id}")
end

private

def base_path
"/api/accounts/#{account_id}/contacts"
end
end
end
Loading