Skip to content

Commit 3bfe096

Browse files
authored
Merge pull request #100 from mailtrap/email-logs-api
Add support for Email Logs API
2 parents 457328b + 263f97e commit 3bfe096

17 files changed

Lines changed: 928 additions & 0 deletions

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
## [Unreleased]
2+
3+
- Add Email Logs API (list and get email sending logs with filters and cursor pagination)
4+
15
## [2.9.0] - 2026-03-13
26

37
- Add Sending Stats API

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,7 @@ Email API:
178178
- Batch Sending – [`batch.rb`](examples/batch.rb)
179179
- Sending Domains API – [`sending_domains_api.rb`](examples/sending_domains_api.rb)
180180
- Sending Stats API – [`stats_api.rb`](examples/stats_api.rb)
181+
- Email Logs API – [`email_logs_api.rb`](examples/email_logs_api.rb)
181182

182183
Email Sandbox (Testing):
183184

examples/email_logs_api.rb

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
require 'time'
2+
require 'mailtrap'
3+
4+
account_id = 3229
5+
client = Mailtrap::Client.new(api_key: 'your-api-key')
6+
email_logs = Mailtrap::EmailLogsAPI.new(account_id, client)
7+
8+
# Set your API credentials as environment variables
9+
# export MAILTRAP_API_KEY='your-api-key'
10+
# export MAILTRAP_ACCOUNT_ID=your-account-id
11+
#
12+
# email_logs = Mailtrap::EmailLogsAPI.new
13+
14+
# List email logs (first page)
15+
response = email_logs.list
16+
# => #<struct Mailtrap::EmailLogsListResponse
17+
# messages=[#<struct Mailtrap::EmailLogMessage message_id="...", status="delivered", ...>, ...],
18+
# total_count=150,
19+
# next_page_cursor="b2c3d4e5-f6a7-8901-bcde-f12345678901">
20+
21+
response.messages.each { |m| puts "#{m.message_id} #{m.status} #{m.subject}" }
22+
23+
# List with filters (date range: last 2 days, recipient, status)
24+
sent_after = (Time.now.utc - (2 * 24 * 3600)).iso8601
25+
sent_before = Time.now.utc.iso8601
26+
filters = {
27+
sent_after: sent_after,
28+
sent_before: sent_before,
29+
subject: { operator: 'not_empty' },
30+
to: { operator: 'ci_equal', value: 'recipient@example.com' },
31+
category: { operator: 'equal', value: %w[Newsletter Alert] }
32+
}
33+
response = email_logs.list(filters: filters)
34+
35+
# List next page using cursor from previous response (keep same filters)
36+
response = email_logs.list(filters: filters, search_after: response.next_page_cursor) if response.next_page_cursor
37+
38+
# Alternative: iterate all pages with list_each (no manual cursor handling)
39+
email_logs.list_each(filters: filters) { |m| puts "#{m.message_id} #{m.status} #{m.subject}" }
40+
# Or as enumerator:
41+
email_logs.list_each(filters: filters).each { |m| puts m.message_id }
42+
43+
# Get a single message by ID (includes events and raw_message_url)
44+
message_id = response.messages.first&.message_id
45+
if message_id
46+
message = email_logs.get(message_id)
47+
# => #<struct Mailtrap::EmailLogMessage
48+
# message_id="a1b2c3d4-...", status="delivered", subject="Welcome", ...,
49+
# raw_message_url="https://storage.../signed/eml/...",
50+
# events=[#<struct Mailtrap::EmailLogEvent event_type="delivery", ...>, ...]>
51+
52+
puts message.raw_message_url
53+
message.events&.each { |e| puts "#{e.event_type} at #{e.created_at}" }
54+
end

lib/mailtrap.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
require_relative 'mailtrap/contact_imports_api'
1515
require_relative 'mailtrap/suppressions_api'
1616
require_relative 'mailtrap/sending_domains_api'
17+
require_relative 'mailtrap/email_logs_api'
1718
require_relative 'mailtrap/projects_api'
1819
require_relative 'mailtrap/inboxes_api'
1920
require_relative 'mailtrap/sandbox_messages_api'

lib/mailtrap/email_log_event.rb

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
# frozen_string_literal: true
2+
3+
module Mailtrap
4+
# Data Transfer Object for an email log event (delivery, open, click, bounce, etc.)
5+
# @see https://docs.mailtrap.io/developers/email-sending/email-logs
6+
# @attr_reader event_type [String] One of: delivery, open, click, soft_bounce, bounce, spam, unsubscribe, suspension,
7+
# reject
8+
# @attr_reader created_at [String] ISO 8601 timestamp
9+
# @attr_reader details [EmailLogEventDetails::Delivery, EmailLogEventDetails::Open, EmailLogEventDetails::Click,
10+
# EmailLogEventDetails::Bounce, EmailLogEventDetails::Spam, EmailLogEventDetails::Unsubscribe,
11+
# EmailLogEventDetails::Reject] Type-specific event details
12+
EmailLogEvent = Struct.new(
13+
:event_type,
14+
:created_at,
15+
:details,
16+
keyword_init: true
17+
)
18+
end
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
# frozen_string_literal: true
2+
3+
module Mailtrap
4+
# Type-specific event detail structs for EmailLogEvent. Use event_type to determine which details schema applies.
5+
# @see https://docs.mailtrap.io/developers/email-sending/email-logs
6+
module EmailLogEventDetails
7+
# For event_type = delivery
8+
Delivery = Struct.new(:sending_ip, :recipient_mx, :email_service_provider, keyword_init: true)
9+
10+
# For event_type = open
11+
Open = Struct.new(:web_ip_address, keyword_init: true)
12+
13+
# For event_type = click
14+
Click = Struct.new(:click_url, :web_ip_address, keyword_init: true)
15+
16+
# For event_type = soft_bounce or bounce
17+
Bounce = Struct.new(
18+
:sending_ip,
19+
:recipient_mx,
20+
:email_service_provider,
21+
:email_service_provider_status,
22+
:email_service_provider_response,
23+
:bounce_category,
24+
keyword_init: true
25+
)
26+
27+
# For event_type = spam
28+
Spam = Struct.new(:spam_feedback_type, keyword_init: true)
29+
30+
# For event_type = unsubscribe
31+
Unsubscribe = Struct.new(:web_ip_address, keyword_init: true)
32+
33+
# For event_type = suspension or reject
34+
Reject = Struct.new(:reject_reason, keyword_init: true)
35+
36+
DETAIL_STRUCTS = {
37+
'delivery' => Delivery,
38+
'open' => Open,
39+
'click' => Click,
40+
'soft_bounce' => Bounce,
41+
'bounce' => Bounce,
42+
'spam' => Spam,
43+
'unsubscribe' => Unsubscribe,
44+
'suspension' => Reject,
45+
'reject' => Reject
46+
}.freeze
47+
48+
# Builds the appropriate detail struct from API response.
49+
# @param event_type [String] Known event type (delivery, open, click, etc.)
50+
# @param hash [Hash] Symbol-keyed details from parsed JSON
51+
# @return [Delivery, Open, Click, Bounce, Spam, Unsubscribe, Reject]
52+
# @raise [ArgumentError] when event_type is nil or not in DETAIL_STRUCTS
53+
def self.build(event_type, hash)
54+
struct_class = DETAIL_STRUCTS[event_type.to_s]
55+
raise ArgumentError, "Unknown event_type: #{event_type.inspect}" unless struct_class
56+
57+
attrs = hash.slice(*struct_class.members)
58+
struct_class.new(**attrs)
59+
end
60+
end
61+
end

lib/mailtrap/email_log_message.rb

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
# frozen_string_literal: true
2+
3+
module Mailtrap
4+
# Data Transfer Object for an email log message (summary in list, full details when fetched by ID)
5+
# @see https://docs.mailtrap.io/developers/email-sending/email-logs
6+
# @attr_reader message_id [String] Message UUID
7+
# @attr_reader status [String] delivered, not_delivered, enqueued, opted_out
8+
# @attr_reader subject [String, nil] Email subject
9+
# @attr_reader from [String] Sender address
10+
# @attr_reader to [String] Recipient address
11+
# @attr_reader sent_at [String] ISO 8601 timestamp
12+
# @attr_reader client_ip [String, nil] Client IP that sent the email
13+
# @attr_reader category [String, nil] Message category
14+
# @attr_reader custom_variables [Hash, nil] Custom variables
15+
# @attr_reader sending_stream [String] transactional or bulk
16+
# @attr_reader sending_domain_id [Integer] Sending domain ID
17+
# @attr_reader template_id [Integer, nil] Template ID if sent from template
18+
# @attr_reader template_variables [Hash, nil] Template variables
19+
# @attr_reader opens_count [Integer] Number of opens
20+
# @attr_reader clicks_count [Integer] Number of clicks
21+
# @attr_reader raw_message_url [String, nil] Signed URL to download raw .eml (only when fetched by ID)
22+
# @attr_reader events [Array<EmailLogEvent>, nil] Event list (only when fetched by ID)
23+
EmailLogMessage = Struct.new(
24+
:message_id,
25+
:status,
26+
:subject,
27+
:from,
28+
:to,
29+
:sent_at,
30+
:client_ip,
31+
:category,
32+
:custom_variables,
33+
:sending_stream,
34+
:sending_domain_id,
35+
:template_id,
36+
:template_variables,
37+
:opens_count,
38+
:clicks_count,
39+
:raw_message_url,
40+
:events,
41+
keyword_init: true
42+
)
43+
end

lib/mailtrap/email_logs_api.rb

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
# frozen_string_literal: true
2+
3+
require_relative 'base_api'
4+
require_relative 'email_log_message'
5+
require_relative 'email_log_event'
6+
require_relative 'email_log_event_details'
7+
require_relative 'email_logs_list_response'
8+
9+
module Mailtrap
10+
class EmailLogsAPI
11+
include BaseAPI
12+
13+
self.response_class = EmailLogMessage
14+
15+
# Lists email logs with optional filters and cursor-based pagination.
16+
#
17+
# @param filters [Hash, nil] Optional filters. Top-level date keys use string values (ISO 8601);
18+
# other keys use +{ operator:, value: }+. +value+ can be a single value or an Array for
19+
# operators that accept multiple values (e.g. +equal+, +not_equal+, +ci_equal+, +ci_not_equal+).
20+
# Examples:
21+
# +{ sent_after: "2025-01-01T00:00:00Z", sent_before: "2025-01-31T23:59:59Z" }+
22+
# +{ to: { operator: "ci_equal", value: "recipient@example.com" } }+
23+
# +{ category: { operator: "equal", value: ["Welcome Email", "Transactional Email"] } }+
24+
# @param search_after [String, nil] Message UUID cursor for the next page (from previous +next_page_cursor+)
25+
# @return [EmailLogsListResponse] messages, total_count, and next_page_cursor
26+
# @!macro api_errors
27+
def list(filters: nil, search_after: nil)
28+
query_params = build_list_query_params(filters:, search_after:)
29+
30+
response = client.get(base_path, query_params)
31+
32+
build_list_response(response)
33+
end
34+
35+
# Iterates over all email log messages matching the filters, automatically fetching each page.
36+
# Use this when you want to process every message without manually handling +next_page_cursor+.
37+
#
38+
# @param filters [Hash, nil] Same as +list+
39+
# @yield [EmailLogMessage] Gives each message from every page when a block is given.
40+
# @return [Enumerator<EmailLogMessage>] if no block given; otherwise the result of the block
41+
# @!macro api_errors
42+
def list_each(filters: nil, &block)
43+
return to_enum(__method__, filters: filters) unless block
44+
45+
search_after = nil
46+
loop do
47+
response = list(filters: filters, search_after: search_after)
48+
response.messages.each { |message| block.call(message) }
49+
break if response.next_page_cursor.nil?
50+
51+
search_after = response.next_page_cursor
52+
end
53+
end
54+
55+
# Fetches a single email log message by ID.
56+
#
57+
# @param sending_message_id [String] Message UUID
58+
# @return [EmailLogMessage] Message with events and raw_message_url when available
59+
# @!macro api_errors
60+
def get(sending_message_id)
61+
base_get(sending_message_id)
62+
end
63+
64+
private
65+
66+
def base_path
67+
"/api/accounts/#{account_id}/email_logs"
68+
end
69+
70+
def build_list_query_params(filters:, search_after:)
71+
{}.tap do |params|
72+
params[:search_after] = search_after if search_after
73+
params.merge!(flatten_filters(filters))
74+
end
75+
end
76+
77+
# Flattens a filters Hash into query param keys expected by the API (deepObject style).
78+
# Scalar values => filters[key]; Hashes with :operator/:value => filters[key][operator], filters[key][value].
79+
# When :value is an Array, the key is repeated (e.g. filters[category][value]=A&filters[category][value]=B)
80+
# for operators that accept multiple values (e.g. equal, not_equal, ci_equal, ci_not_equal).
81+
def flatten_filters(filters)
82+
return {} if filters.nil? || filters.empty?
83+
84+
filters.each_with_object({}) do |(key, value), result|
85+
if value.is_a?(Hash)
86+
flatten_filter_hash(key, value, result)
87+
else
88+
result["filters[#{key}]"] = value.to_s
89+
end
90+
end
91+
end
92+
93+
def flatten_filter_hash(parent_key, hash, result)
94+
hash.each do |key, value|
95+
if value.is_a?(Array)
96+
result["filters[#{parent_key}][#{key}][]"] = value.map(&:to_s)
97+
else
98+
result["filters[#{parent_key}][#{key}]"] = value.to_s
99+
end
100+
end
101+
end
102+
103+
def build_list_response(response)
104+
EmailLogsListResponse.new(
105+
messages: Array(response[:messages]).map { |item| handle_response(item) },
106+
total_count: response[:total_count],
107+
next_page_cursor: response[:next_page_cursor]
108+
)
109+
end
110+
111+
def handle_response(response)
112+
build_message_entity(response)
113+
end
114+
115+
def build_message_entity(hash)
116+
attrs = hash.slice(*EmailLogMessage.members)
117+
attrs[:events] = build_events(attrs[:events]) if attrs[:events]
118+
119+
EmailLogMessage.new(**attrs)
120+
end
121+
122+
def build_events(events_array)
123+
Array(events_array).map do |e|
124+
EmailLogEvent.new(
125+
event_type: e[:event_type],
126+
created_at: e[:created_at],
127+
details: EmailLogEventDetails.build(e[:event_type], e[:details])
128+
)
129+
end
130+
end
131+
end
132+
end
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
# frozen_string_literal: true
2+
3+
module Mailtrap
4+
# Response from listing email logs (paginated)
5+
# @see https://docs.mailtrap.io/developers/email-sending/email-logs
6+
# @attr_reader messages [Array<EmailLogMessage>] Page of message summaries
7+
# @attr_reader total_count [Integer] Total number of messages matching filters
8+
# @attr_reader next_page_cursor [String, nil] Message UUID to use as search_after for next page, or nil
9+
EmailLogsListResponse = Struct.new(
10+
:messages,
11+
:total_count,
12+
:next_page_cursor,
13+
keyword_init: true
14+
)
15+
end

0 commit comments

Comments
 (0)