Skip to content
Merged
Show file tree
Hide file tree
Changes from 12 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 lib/mailtrap.rb
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
require_relative 'mailtrap/contact_imports_api'
require_relative 'mailtrap/suppressions_api'
require_relative 'mailtrap/projects_api'
require_relative 'mailtrap/sandbox_messages_api'
require_relative 'mailtrap/inboxes_api'
require_relative 'mailtrap/sending_domains_api'

Expand Down
60 changes: 38 additions & 22 deletions lib/mailtrap/client.rb
Original file line number Diff line number Diff line change
Expand Up @@ -221,8 +221,11 @@ def delete(path)

private

def http_client_for(host)
@http_clients[host] ||= Net::HTTP.new(host, api_port).tap { |client| client.use_ssl = true }
def validate_args!(api_key, api_port, bulk, sandbox, inbox_id)
raise ArgumentError, 'api_key is required' if api_key.nil?
raise ArgumentError, 'api_port is required' if api_port.nil?
raise ArgumentError, 'bulk stream is not applicable for sandbox API' if bulk && sandbox
raise ArgumentError, 'inbox_id is required for sandbox API' if sandbox && inbox_id.nil?
end

def select_api_host(bulk:, sandbox:)
Expand All @@ -235,14 +238,6 @@ def select_api_host(bulk:, sandbox:)
end
end

def send_path
"/api/send#{"/#{inbox_id}" if sandbox}"
end

def batch_request_path
"/api/batch#{"/#{inbox_id}" if sandbox}"
end

def perform_request(method:, host:, path:, query_params: {}, body: nil)
http_client = http_client_for(host)

Expand All @@ -254,6 +249,10 @@ def perform_request(method:, host:, path:, query_params: {}, body: nil)
handle_response(response)
end

def http_client_for(host)
@http_clients[host] ||= Net::HTTP.new(host, api_port).tap { |client| client.use_ssl = true }
end

def setup_request(method, uri_or_path, body = nil)
request = case method
when :get
Expand All @@ -279,17 +278,17 @@ def setup_request(method, uri_or_path, body = nil)
def handle_response(response) # rubocop:disable Metrics/CyclomaticComplexity, Metrics/MethodLength
case response
when Net::HTTPOK, Net::HTTPCreated
json_response(response.body)
parse_response(response)
when Net::HTTPNoContent
nil
when Net::HTTPBadRequest
raise Mailtrap::Error, ['bad request'] if response.body.empty?

raise Mailtrap::Error, response_errors(response.body)
raise Mailtrap::Error, response_errors(response)
when Net::HTTPUnauthorized
raise Mailtrap::AuthorizationError, response_errors(response.body)
raise Mailtrap::AuthorizationError, response_errors(response)
when Net::HTTPForbidden
raise Mailtrap::RejectionError, response_errors(response.body)
raise Mailtrap::RejectionError, response_errors(response)
when Net::HTTPPayloadTooLarge
raise Mailtrap::MailSizeError, ['message too large']
when Net::HTTPTooManyRequests
Expand All @@ -303,20 +302,37 @@ def handle_response(response) # rubocop:disable Metrics/CyclomaticComplexity, Me
end
end

def response_errors(body)
parsed_body = json_response(body)
Array(parsed_body[:errors] || parsed_body[:error])
def parse_response(response)
if json_response?(response)
json_response(response.body)
else
response.body
end
end

def response_errors(response)
if json_response?(response)
parsed_body = json_response(response.body)
Array(parsed_body[:errors] || parsed_body[:error])
else
[response.body]
end
end

def json_response?(response)
response.content_type == 'application/json'
end

def json_response(body)
JSON.parse(body, symbolize_names: true)
end

def validate_args!(api_key, api_port, bulk, sandbox, inbox_id)
raise ArgumentError, 'api_key is required' if api_key.nil?
raise ArgumentError, 'api_port is required' if api_port.nil?
raise ArgumentError, 'bulk stream is not applicable for sandbox API' if bulk && sandbox
raise ArgumentError, 'inbox_id is required for sandbox API' if sandbox && inbox_id.nil?
def send_path
"/api/send#{"/#{inbox_id}" if sandbox}"
end

def batch_request_path
"/api/batch#{"/#{inbox_id}" if sandbox}"
end
end
end
54 changes: 54 additions & 0 deletions lib/mailtrap/sandbox_message.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
# frozen_string_literal: true

module Mailtrap
# Data Transfer Object for Sandbox Message
# @see https://docs.mailtrap.io/developers/email-sandbox/email-sandbox-api/messages
# @attr_reader id [Integer] The message ID
# @attr_reader inbox_id [Integer] The inbox ID
# @attr_reader subject [String] The message subject
# @attr_reader sent_at [String] The timestamp when the message was sent
# @attr_reader from_email [String] The sender's email address
# @attr_reader from_name [String] The sender's name
# @attr_reader to_email [String] The recipient's email address
# @attr_reader to_name [String] The recipient's name
# @attr_reader email_size [Integer] The size of the email in bytes
# @attr_reader is_read [Boolean] Whether the message has been read
# @attr_reader created_at [String] The timestamp when the message was created
# @attr_reader updated_at [String] The timestamp when the message was last updated
# @attr_reader html_body_size [Integer] The size of the HTML body in bytes
# @attr_reader text_body_size [Integer] The size of the text body in bytes
# @attr_reader human_size [String] The human-readable size of the email
# @attr_reader html_path [String] The path to the HTML version of the email
# @attr_reader txt_path [String] The path to the text version of the email
# @attr_reader raw_path [String] The path to the raw version of the email
# @attr_reader download_path [String] The path to download the email
# @attr_reader html_source_path [String] The path to the HTML source of the email
# @attr_reader blacklists_report_info [Boolean] Information about blacklists report
# @attr_reader smtp_information [Hash] Information about SMTP
#
SandboxMessage = Struct.new(
:id,
:inbox_id,
:subject,
:sent_at,
:from_email,
:from_name,
:to_email,
:to_name,
:email_size,
:is_read,
:created_at,
:updated_at,
:html_body_size,
:text_body_size,
:human_size,
:html_path,
:txt_path,
:raw_path,
:download_path,
:html_source_path,
:blacklists_report_info,
:smtp_information,
keyword_init: true
)
end
154 changes: 154 additions & 0 deletions lib/mailtrap/sandbox_messages_api.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
# frozen_string_literal: true

require_relative 'base_api'
require_relative 'sandbox_message'

module Mailtrap
class SandboxMessagesAPI

@i7an i7an Feb 10, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Inboxes API but Sandbox Messages API. Let's discuss today.

include BaseAPI

attr_reader :account_id, :inbox_id, :client

self.supported_options = %i[is_read]

self.response_class = SandboxMessage

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I reckon the order should be account_id, inbox_id, client. or maybe its better to use named arguments. though its inconsistent with other APIs. Lets discuss.

@i7an i7an Feb 10, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

And there is inbox_id in the client :)
Probably will address in the new major release.

raise ArgumentError, 'account_id is required' if account_id.nil?
raise ArgumentError, 'inbox_id is required' if inbox_id.nil?

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

cat -n lib/mailtrap/sandbox_messages_api.rb | head -30

Repository: mailtrap/mailtrap-ruby

Length of output: 1217


🏁 Script executed:

# Let's also check if there are any other references to account_id handling
rg "account_id" lib/mailtrap/sandbox_messages_api.rb -A 2 -B 2

Repository: mailtrap/mailtrap-ruby

Length of output: 806


🏁 Script executed:

# Verify the behavior of ENV.fetch vs ENV[]
# ENV.fetch raises KeyError if key doesn't exist
# ENV[] returns nil if key doesn't exist
python3 << 'EOF'
# Simulate Ruby's ENV.fetch behavior
# ENV.fetch('KEY') raises KeyError if not found
# ENV['KEY'] returns nil if not found
print("ENV.fetch behavior: raises KeyError when env var not found")
print("ENV['KEY'] behavior: returns nil when env var not found")
print("\nThe review claim:")
print("- Using ENV.fetch means KeyError is raised (not ArgumentError)")
print("- The `@raise` documentation claims ArgumentError will be raised")
print("- This is a mismatch between documented behavior and actual behavior")
EOF

Repository: mailtrap/mailtrap-ruby

Length of output: 394


🏁 Script executed:

# Check YARD documentation in the file
rg "@raise" lib/mailtrap/sandbox_messages_api.rb -B 5 -A 1

Repository: mailtrap/mailtrap-ruby

Length of output: 756


Align error behavior with the YARD contract.

Line 20 uses ENV.fetch, which raises KeyError when the environment variable is unset, but the documentation declares @raise [ArgumentError]. To match the documented contract, use ENV['MAILTRAP_ACCOUNT_ID'] so the explicit ArgumentError check on line 21 actually executes when the account ID is missing or nil.

💡 Proposed fix
-    def initialize(inbox_id, account_id = ENV.fetch('MAILTRAP_ACCOUNT_ID'), client = Mailtrap::Client.new)
+    def initialize(inbox_id, account_id = ENV['MAILTRAP_ACCOUNT_ID'], client = Mailtrap::Client.new)
       raise ArgumentError, 'account_id is required' if account_id.nil?
       raise ArgumentError, 'inbox_id is required' if inbox_id.nil?
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
# @param inbox_id [Integer] The inbox ID
# @param account_id [Integer] The account ID
# @param client [Mailtrap::Client] The client instance
# @raise [ArgumentError] If account_id is nil
def initialize(inbox_id, account_id = ENV.fetch('MAILTRAP_ACCOUNT_ID'), client = Mailtrap::Client.new)
raise ArgumentError, 'account_id is required' if account_id.nil?
raise ArgumentError, 'inbox_id is required' if inbox_id.nil?
# `@param` inbox_id [Integer] The inbox ID
# `@param` account_id [Integer] The account ID
# `@param` client [Mailtrap::Client] The client instance
# `@raise` [ArgumentError] If account_id is nil
def initialize(inbox_id, account_id = ENV['MAILTRAP_ACCOUNT_ID'], client = Mailtrap::Client.new)
raise ArgumentError, 'account_id is required' if account_id.nil?
raise ArgumentError, 'inbox_id is required' if inbox_id.nil?
🤖 Prompt for AI Agents
In `@lib/mailtrap/sandbox_messages_api.rb` around lines 16 - 22, The initialize
method currently uses ENV.fetch('MAILTRAP_ACCOUNT_ID') which raises KeyError and
breaks the documented `@raise` [ArgumentError] contract; change the default to
ENV['MAILTRAP_ACCOUNT_ID'] so the explicit guard (raise ArgumentError,
'account_id is required') for account_id in initialize(inbox_id, account_id =
ENV['MAILTRAP_ACCOUNT_ID'], client = Mailtrap::Client.new) will run when the env
var is missing or nil, leaving the inbox_id guard (raise ArgumentError,
'inbox_id is required') and the rest of the initializer unchanged.


@account_id = account_id

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

can you use super(account_id, client)?

@inbox_id = inbox_id
@client = client
end

# Retrieves a specific sandbox message from inbox
# @param message_id [Integer] The sandbox message ID
# @return [SandboxMessage] Sandbox message object
# @!macro api_errors
def get(message_id)
base_get(message_id)
end

# Deletes a sandbox message
# @param message_id [Integer] The sandbox message ID
# @return [SandboxMessage] Deleted Sandbox message object
# @!macro api_errors
def delete(message_id)
base_delete(message_id)
end

# Updates an existing sandbox message
# @param message_id [Integer] The sandbox message ID
# @param [Hash] options The parameters to update
# @return [SandboxMessage] Updated Sandbox message object
# @!macro api_errors
# @raise [ArgumentError] If invalid options are provided
def update(message_id, options)
base_update(message_id, options)
end

# Lists all sandbox messages for the account, limited up to 30 at once
# @param search [String] Search query string. Matches subject, to_email, and to_name.
# @param last_id [Integer] If specified, a page of records before last_id is returned.
# Overrides page if both are given.
# @param page [Integer] Page number for paginated results.
# @return [Array<SandboxMessage>] Array of sandbox message objects
# @!macro api_errors
def list(search: nil, last_id: nil, page: nil)
query_params = {}
query_params[:search] = search unless search.nil?
query_params[:last_id] = last_id unless last_id.nil?
query_params[:page] = page unless page.nil?
Comment thread
coderabbitai[bot] marked this conversation as resolved.

base_list(query_params)
end

# Forward message to an email address.
# @param message_id [Integer] The Sandbox message ID
# @param email [String] The email to forward sandbox message to
# @return [String] Forwarded message confirmation
# @!macro api_errors
def forward_message(message_id:, email:)
client.post("#{base_path}/#{message_id}/forward", { email: email })
end

# Get message spam score
# @param message_id [Integer] The Sandbox message ID
# @return [Hash] Spam report
# @!macro api_errors
def get_spam_score(message_id)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

get_ prefix should be avoided.

client.get("#{base_path}/#{message_id}/spam_report")
end

# Get message HTML analysis
# @param message_id [Integer] The Sandbox message ID
# @return [Hash] brief HTML report
# @!macro api_errors
def get_html_analysis(message_id)
client.get("#{base_path}/#{message_id}/analyze")
end

# Get text message
# @param message_id [Integer] The Sandbox message ID
# @return [String] text email body
# @!macro api_errors
def get_text_message(message_id)
client.get("#{base_path}/#{message_id}/body.txt")
end

# Get raw message
# @param message_id [Integer] The Sandbox message ID
# @return [String] raw email body
# @!macro api_errors
def get_raw_message(message_id)
client.get("#{base_path}/#{message_id}/body.raw")
end

# Get message source
# @param message_id [Integer] The Sandbox message ID
# @return [String] HTML source of a message.
# @!macro api_errors
def get_html_source(message_id)
client.get("#{base_path}/#{message_id}/body.htmlsource")
end

# Get formatted HTML email body. Not applicable for plain text emails.
# @param message_id [Integer] The Sandbox message ID
# @return [String] message body in html format.
# @!macro api_errors
def get_html_message(message_id)
client.get("#{base_path}/#{message_id}/body.html")
end

# Get message as EML
# @param message_id [Integer] The Sandbox message ID
# @return [Hash] mail headers of the message.
# @!macro api_errors
def get_message_as_eml(message_id)
client.get("#{base_path}/#{message_id}/body.eml")
end

# Get mail headers
# @param message_id [Integer] The Sandbox message ID
# @return [Hash] mail headers of the message.
# @!macro api_errors
def get_mail_headers(message_id)
client.get("#{base_path}/#{message_id}/mail_headers")
end

private

def base_path
"/api/accounts/#{account_id}/inboxes/#{inbox_id}/messages"
end

def wrap_request(options)
{ message: options }
end
end
end
Loading