Skip to content
Merged
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
7 changes: 7 additions & 0 deletions .gemini/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"codeReview": {
"enabled": true,
"autoReview": true,
"reviewStyle": "concise"
}
}
15 changes: 15 additions & 0 deletions lib/lettermint.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,21 @@
require_relative 'lettermint/http_client'
require_relative 'lettermint/email_message'
require_relative 'lettermint/webhook'

# Resources (Team API)
require_relative 'lettermint/resources/base'
require_relative 'lettermint/resources/team'
require_relative 'lettermint/resources/domains'
require_relative 'lettermint/resources/projects'
require_relative 'lettermint/resources/routes'
require_relative 'lettermint/resources/webhooks'
require_relative 'lettermint/resources/messages'
require_relative 'lettermint/resources/suppressions'
require_relative 'lettermint/resources/stats'

# API Clients
require_relative 'lettermint/sending_api'
require_relative 'lettermint/team_api'
require_relative 'lettermint/client'

module Lettermint
Expand Down
71 changes: 3 additions & 68 deletions lib/lettermint/client.rb
Original file line number Diff line number Diff line change
@@ -1,72 +1,7 @@
# frozen_string_literal: true

module Lettermint
class Client
attr_reader :configuration

def initialize(api_token:, base_url: nil, timeout: nil)
validate_api_token!(api_token)

@configuration = Configuration.new
@configuration.base_url = base_url || Lettermint.configuration.base_url
@configuration.timeout = timeout || Lettermint.configuration.timeout

yield @configuration if block_given?

@http_client = HttpClient.new(
api_token: api_token,
base_url: @configuration.base_url,
timeout: @configuration.timeout
)
end

def email
EmailMessage.new(http_client: @http_client)
end

# Makes a GET request to an arbitrary API endpoint.
#
# @param path [String] The API endpoint path (e.g., '/domains')
# @param params [Hash, nil] Query parameters to include in the request
# @param headers [Hash, nil] Additional HTTP headers
# @return [Hash] The parsed JSON response body
def get(path, params: nil, headers: nil)
@http_client.get(path: path, params: params, headers: headers)
end

# Makes a POST request to an arbitrary API endpoint.
#
# @param path [String] The API endpoint path
# @param data [Hash, nil] The request body (will be JSON-encoded)
# @param headers [Hash, nil] Additional HTTP headers
# @return [Hash] The parsed JSON response body
def post(path, data: nil, headers: nil)
@http_client.post(path: path, data: data, headers: headers)
end

# Makes a PUT request to an arbitrary API endpoint.
#
# @param path [String] The API endpoint path
# @param data [Hash, nil] The request body (will be JSON-encoded)
# @param headers [Hash, nil] Additional HTTP headers
# @return [Hash] The parsed JSON response body
def put(path, data: nil, headers: nil)
@http_client.put(path: path, data: data, headers: headers)
end

# Makes a DELETE request to an arbitrary API endpoint.
#
# @param path [String] The API endpoint path
# @param headers [Hash, nil] Additional HTTP headers
# @return [Hash] The parsed JSON response body
def delete(path, headers: nil)
@http_client.delete(path: path, headers: headers)
end

private

def validate_api_token!(token)
raise ArgumentError, 'API token cannot be empty' if token.nil? || token.to_s.strip.empty?
end
end
# Backward compatibility: Client is an alias for SendingAPI.
# Use Lettermint::SendingAPI explicitly for clarity.
Client = SendingAPI
end
12 changes: 10 additions & 2 deletions lib/lettermint/http_client.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

module Lettermint
class HttpClient
def initialize(api_token:, base_url:, timeout:)
def initialize(api_token:, base_url:, timeout:, auth_scheme: :project)
normalized_url = "#{base_url.chomp('/')}/"
@connection = Faraday.new(url: normalized_url) do |f|
f.request :json
Expand All @@ -14,7 +14,7 @@ def initialize(api_token:, base_url:, timeout:)
f.headers = {
'Content-Type' => 'application/json',
'Accept' => 'application/json',
'x-lettermint-token' => api_token,
**auth_headers(api_token, auth_scheme),
'User-Agent' => "Lettermint/#{Lettermint::VERSION} (Ruby; ruby #{RUBY_VERSION})"
}
end
Expand Down Expand Up @@ -57,6 +57,14 @@ def delete(path:, headers: nil)

private

def auth_headers(token, scheme)
case scheme.to_sym
when :project then { 'x-lettermint-token' => token }
when :team then { 'Authorization' => "Bearer #{token}" }
else raise ArgumentError, "Unknown auth_scheme: #{scheme}"
end
end
Comment on lines +60 to +66

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

The scheme parameter is compared directly in the case statement. To make the client more robust against callers passing strings instead of symbols, it is safer to convert the scheme to a symbol before comparison.

    def auth_headers(token, scheme)
      case scheme.to_sym
      when :project then { 'x-lettermint-token' => token }
      when :team    then { 'Authorization' => "Bearer #{token}" }
      else raise ArgumentError, "Unknown auth_scheme: #{scheme}"
      end
    end


def with_error_handling
response = yield
handle_response(response)
Expand Down
33 changes: 33 additions & 0 deletions lib/lettermint/resources/base.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# frozen_string_literal: true

module Lettermint
module Resources
# Base class for Team API resources providing shared functionality.
class Base
def initialize(http_client:)
@http_client = http_client
end

private

# Builds query parameters for list endpoints.
# @param page_size [Integer, nil] Number of items per page
# @param page_cursor [String, nil] Cursor for pagination
# @param sort [String, nil] Sort field (prefix with - for descending)
# @param include [String, nil] Related resources to include
# @param filters [Hash] Filter parameters (converted to filter[key]=value)
# @return [Hash, nil] Query parameters hash or nil if empty
def build_params(page_size: nil, page_cursor: nil, sort: nil, include: nil, **filters)
params = {
'page[size]' => page_size,
'page[cursor]' => page_cursor,
'sort' => sort,
'include' => include
}.compact

filters.each { |k, v| params["filter[#{k}]"] = v unless v.nil? }
params.empty? ? nil : params
end
end
end
end
66 changes: 66 additions & 0 deletions lib/lettermint/resources/domains.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
# frozen_string_literal: true

module Lettermint
module Resources
# Domains resource for managing sending domains and DNS verification.
class Domains < Base
# List all domains.
# @param page_size [Integer, nil] Number of items per page (default: 30)
# @param page_cursor [String, nil] Cursor for pagination
# @param sort [String, nil] Sort field: domain, created_at, status_changed_at (prefix - for desc)
# @param status [String, nil] Filter by status (verified, partially_verified, etc.)
# @param domain [String, nil] Filter by domain (partial match)
# @return [Hash] Paginated list of domains
def list(page_size: nil, page_cursor: nil, sort: nil, status: nil, domain: nil)
params = build_params(page_size:, page_cursor:, sort:, status:, domain:)
@http_client.get(path: '/domains', params: params)
end

# Create a new domain.
# @param domain [String] Domain name (max 255 chars)
# @return [Hash] Created domain data
def create(domain:)
@http_client.post(path: '/domains', data: { domain: domain })
end

# Get domain details.
# @param id [String] Domain ID
# @param include [String, nil] Related data to include (dnsRecords, dnsRecordsCount, dnsRecordsExists)
# @return [Hash] Domain data with optional includes
def find(id, include: nil)
params = build_params(include: include)
@http_client.get(path: "/domains/#{id}", params: params)
end

# Delete a domain.
# @param id [String] Domain ID
# @return [Hash] Confirmation message
def delete(id)
@http_client.delete(path: "/domains/#{id}")
end

# Verify all DNS records for a domain.
# @param id [String] Domain ID
# @return [Hash] Verification result
def verify_dns(id)
@http_client.post(path: "/domains/#{id}/dns-records/verify")
end

# Verify a specific DNS record.
# @param domain_id [String] Domain ID
# @param record_id [String] DNS record ID
# @return [Hash] Verification result
def verify_dns_record(domain_id, record_id)
@http_client.post(path: "/domains/#{domain_id}/dns-records/#{record_id}/verify")
end

# Update projects associated with a domain.
# @param id [String] Domain ID
# @param project_ids [Array<String>] Array of project UUIDs
# @return [Hash] Updated domain data
def update_projects(id, project_ids:)
@http_client.put(path: "/domains/#{id}/projects", data: { project_ids: project_ids })
end
end
end
end
81 changes: 81 additions & 0 deletions lib/lettermint/resources/messages.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
# frozen_string_literal: true

module Lettermint
module Resources
# Messages resource for viewing sent and received messages.
class Messages < Base
# List messages.
# @param page_size [Integer, nil] Number of items per page (default: 30)
# @param page_cursor [String, nil] Cursor for pagination
# @param sort [String, nil] Sort field: type, status, from_email, subject, created_at, status_changed_at
# @param type [String, nil] Filter: inbound, outbound
# @param status [String, nil] Filter by status
# @param route_id [String, nil] Filter by route ID
# @param domain_id [String, nil] Filter by domain ID
# @param tag [String, nil] Filter by tag
# @param from_email [String, nil] Filter by sender email
# @param subject [String, nil] Filter by subject
# @param from_date [String, nil] Filter from date (Y-m-d)
# @param to_date [String, nil] Filter to date (Y-m-d)
# @return [Hash] Paginated list of messages
# rubocop:disable Metrics/ParameterLists
def list(page_size: nil, page_cursor: nil, sort: nil, type: nil, status: nil,
route_id: nil, domain_id: nil, tag: nil, from_email: nil, subject: nil,
from_date: nil, to_date: nil)
params = build_params(
page_size: page_size,
page_cursor: page_cursor,
sort: sort,
type: type,
status: status,
route_id: route_id,
domain_id: domain_id,
tag: tag,
from_email: from_email,
subject: subject,
from_date: from_date,
to_date: to_date
)
@http_client.get(path: '/messages', params: params)
end
Comment on lines +6 to +40

Copilot AI Apr 2, 2026

Copy link

Choose a reason for hiding this comment

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

This resource adds several endpoints (list, find, events, source, html, text) but there are no specs covering the request paths/params or the expected response parsing. Add a spec/lettermint/resources/messages_spec.rb to validate each endpoint and content-type handling (JSON vs plain text).

Copilot uses AI. Check for mistakes.
# rubocop:enable Metrics/ParameterLists

# Get message details.
# @param id [String] Message ID
# @return [Hash] Message data
def find(id)
@http_client.get(path: "/messages/#{id}")
end

# Get message events (delivery history).
# @param id [String] Message ID
# @param sort [String, nil] Sort field: timestamp, event
# @return [Hash] List of message events
def events(id, sort: nil)
params = sort ? { 'sort' => sort } : nil
@http_client.get(path: "/messages/#{id}/events", params: params)
end

# Get raw message source (RFC822 format).
# @param id [String] Message ID
# @return [String] Raw message source (message/rfc822)
def source(id)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

high

The HttpClient defaults to Accept: application/json. Since this endpoint returns raw RFC822 message source, you should explicitly set the Accept header to message/rfc822 to ensure the server provides the correct content type and the client handles it as a raw string.

      def source(id)
        @http_client.get(path: "/messages/#{id}/source", headers: { 'Accept' => 'message/rfc822' })
      end

@http_client.get(path: "/messages/#{id}/source", headers: { 'Accept' => 'message/rfc822' })
end

# Get message HTML body.
# @param id [String] Message ID
# @return [String] HTML content (text/html)
def html(id)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

high

The HttpClient defaults to Accept: application/json. Since this endpoint returns HTML content, you should explicitly set the Accept header to text/html.

      def html(id)
        @http_client.get(path: "/messages/#{id}/html", headers: { 'Accept' => 'text/html' })
      end

@http_client.get(path: "/messages/#{id}/html", headers: { 'Accept' => 'text/html' })
end

# Get message plain text body.
# @param id [String] Message ID
# @return [String] Plain text content (text/plain)
def text(id)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

high

The HttpClient defaults to Accept: application/json. Since this endpoint returns plain text content, you should explicitly set the Accept header to text/plain.

      def text(id)
        @http_client.get(path: "/messages/#{id}/text", headers: { 'Accept' => 'text/plain' })
      end

@http_client.get(path: "/messages/#{id}/text", headers: { 'Accept' => 'text/plain' })
end
end
end
end
Loading
Loading