-
Notifications
You must be signed in to change notification settings - Fork 8
Add support for Email Logs API #100
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
mklocek
wants to merge
1
commit into
main
Choose a base branch
from
email-logs-api
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 | ||
| end | ||
| end | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 |
77 changes: 77 additions & 0 deletions
77
...ailLogsAPI/_get/maps_response_data_to_EmailLogMessage_with_events_and_raw_message_url.yml
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Oops, something went wrong.
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Handle unknown event types gracefully.
If
event_typeisnilor an unrecognized value (e.g., a new event type added by the API),struct_classwill benil, causingnil.membersto raiseNoMethodError.🛡️ 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) endAlternatively, raise a descriptive error if failing loudly is preferred.
🤖 Prompt for AI Agents