Skip to content
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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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):

Expand Down
49 changes: 49 additions & 0 deletions examples/email_logs_api.rb
Original file line number Diff line number Diff line change
@@ -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
# => #<struct Mailtrap::EmailLogsListResponse
# messages=[#<struct Mailtrap::EmailLogMessage message_id="...", status="delivered", ...>, ...],
# 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)
# => #<struct Mailtrap::EmailLogMessage
# message_id="a1b2c3d4-...", status="delivered", subject="Welcome", ...,
# raw_message_url="https://storage.../signed/eml/...",
# events=[#<struct Mailtrap::EmailLogEvent event_type="delivery", ...>, ...]>

puts message.raw_message_url
message.events&.each { |e| puts "#{e.event_type} at #{e.created_at}" }
end
1 change: 1 addition & 0 deletions lib/mailtrap.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
18 changes: 18 additions & 0 deletions lib/mailtrap/email_log_event.rb
Original file line number Diff line number Diff line change
@@ -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
58 changes: 58 additions & 0 deletions lib/mailtrap/email_log_event_details.rb
Original file line number Diff line number Diff line change
@@ -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
Comment on lines +52 to +56
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Handle unknown event types gracefully.

If event_type is nil or an unrecognized value (e.g., a new event type added by the API), struct_class will be nil, causing nil.members to raise NoMethodError.

🛡️ Proposed fix
     def self.build(event_type, hash)
       struct_class = DETAIL_STRUCTS[event_type.to_s]
+      return nil unless struct_class
+
       attrs = hash.slice(*struct_class.members)
       struct_class.new(**attrs)
     end

Alternatively, raise a descriptive error if failing loudly is preferred.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/mailtrap/email_log_event_details.rb` around lines 52 - 56, The build
method in EmailLogEventDetails currently assumes DETAIL_STRUCTS[event_type.to_s]
exists and will raise NoMethodError when event_type is nil or unknown; update
EmailLogEventDetails.build to handle a missing struct_class by checking the
result of DETAIL_STRUCTS[event_type.to_s] (and event_type.nil?) and either (a)
return a sensible default/ fallback struct (or a generic OpenStruct) populated
with the sliced attrs, or (b) raise a clear ArgumentError/RuntimeError with a
descriptive message including the unknown event_type; ensure you reference
DETAIL_STRUCTS and struct_class in the change so callers get a
graceful/failing-but-descriptive behavior from EmailLogEventDetails.build.

end
end
43 changes: 43 additions & 0 deletions lib/mailtrap/email_log_message.rb
Original file line number Diff line number Diff line change
@@ -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<EmailLogEvent>, 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
112 changes: 112 additions & 0 deletions lib/mailtrap/email_logs_api.rb
Original file line number Diff line number Diff line change
@@ -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
15 changes: 15 additions & 0 deletions lib/mailtrap/email_logs_list_response.rb
Original file line number Diff line number Diff line change
@@ -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<EmailLogMessage>] 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

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading