lettermint 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: eaf2c7d68c2a67e9bdcfff25733031fd17e23645ae6d5c2cc73100177ecd8865
4
- data.tar.gz: 7ecfec4398f01936474380abb69c595e9220d1f90e3e990b9261e426e72c1772
3
+ metadata.gz: 4755559f2d03919f11cb88181b581062497106993656e9d1b0a87738769718e5
4
+ data.tar.gz: c5b8df6ea0033b41290f4db33aff06854a1ff02404af2391643d6fc736c38167
5
5
  SHA512:
6
- metadata.gz: fa95bb5a225b54f848de6b62e0e334072207373ab84bbd0a56ba5babc23b47f6b60d4bbdf30097884c6df28c64209ae7956e4c9b551e7b38a735740555a43e09
7
- data.tar.gz: a4e41473d58b93ced91c3219d6074d0eaf6382b61174758cc62a091c8ca59762fc75e1a1e9acd4de5372e5c86083cd3a34767c88d7561ab31a7d0ff7b5c4ad4f
6
+ metadata.gz: a7f78e00a6044e15e2e57efaa365d798a922e1d96fb58d418ba4279cb03785c48c05537e5c0921b177d75d4648c14b8ee0dcf81390dabe9402f21f9485184f5b
7
+ data.tar.gz: b254a766abe3503cbfb5fad105d9acf112e4d9ed2cb1c642caf42289a90f037ecdd3d2768b2148ee2f60590b5e3acff232e7fabdc409d249afb73e3f41120b08
@@ -0,0 +1,7 @@
1
+ {
2
+ "codeReview": {
3
+ "enabled": true,
4
+ "autoReview": true,
5
+ "reviewStyle": "concise"
6
+ }
7
+ }
data/CHANGELOG.md CHANGED
@@ -5,6 +5,27 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.2.0] - 2026-04-01
9
+
10
+ ### Added
11
+
12
+ - `TeamAPI` for managing team resources (members, invitations)
13
+ - `SendingAPI` for domain and sender identity management
14
+ - Resource class infrastructure (`BaseResource`, `Resource`)
15
+ - Raw HTTP methods (`Client#get`, `#post`, `#put`, `#delete`) for arbitrary endpoint access
16
+ - `ConnectionError` for network-level failures
17
+
18
+ ### Changed
19
+
20
+ - HTTP response body validation before JSON parsing
21
+
22
+ ### Fixed
23
+
24
+ - TypeError leak in webhook header parsing
25
+ - Blank recipient validation
26
+
27
+ [0.2.0]: https://github.com/onetimesecret/lettermint-ruby/compare/v0.1.0...v0.2.0
28
+
8
29
  ## [0.1.0] - 2026-02-18
9
30
 
10
31
  ### Added
@@ -1,33 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Lettermint
4
- class Client
5
- attr_reader :configuration
6
-
7
- def initialize(api_token:, base_url: nil, timeout: nil)
8
- validate_api_token!(api_token)
9
-
10
- @configuration = Configuration.new
11
- @configuration.base_url = base_url || Lettermint.configuration.base_url
12
- @configuration.timeout = timeout || Lettermint.configuration.timeout
13
-
14
- yield @configuration if block_given?
15
-
16
- @http_client = HttpClient.new(
17
- api_token: api_token,
18
- base_url: @configuration.base_url,
19
- timeout: @configuration.timeout
20
- )
21
- end
22
-
23
- def email
24
- EmailMessage.new(http_client: @http_client)
25
- end
26
-
27
- private
28
-
29
- def validate_api_token!(token)
30
- raise ArgumentError, 'API token cannot be empty' if token.nil? || token.to_s.strip.empty?
31
- end
32
- end
4
+ # Backward compatibility: Client is an alias for SendingAPI.
5
+ # Use Lettermint::SendingAPI explicitly for clarity.
6
+ Client = SendingAPI
33
7
  end
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Lettermint
4
- class EmailMessage
4
+ class EmailMessage # rubocop:disable Metrics/ClassLength
5
5
  def initialize(http_client:)
6
6
  @http_client = http_client
7
7
  reset
@@ -108,7 +108,8 @@ module Lettermint
108
108
 
109
109
  def validate_required_fields
110
110
  missing = %i[from subject].select { |f| blank?(f) }
111
- missing << :to if @payload[:to].nil? || @payload[:to].none? { |e| e.is_a?(String) && !e.strip.empty? }
111
+ missing << :to unless valid_recipients?
112
+ missing << :body unless body?
112
113
  return if missing.empty?
113
114
 
114
115
  raise ArgumentError, "Missing required field(s): #{missing.join(', ')}"
@@ -118,6 +119,14 @@ module Lettermint
118
119
  @payload[field].nil? || @payload[field].to_s.strip.empty?
119
120
  end
120
121
 
122
+ def valid_recipients?
123
+ @payload[:to]&.any? { |e| e.is_a?(String) && !e.strip.empty? }
124
+ end
125
+
126
+ def body?
127
+ !blank?(:html) || !blank?(:text)
128
+ end
129
+
121
130
  def reset
122
131
  @payload = {}
123
132
  @idempotency_key = nil
@@ -45,6 +45,15 @@ module Lettermint
45
45
 
46
46
  class TimeoutError < Error; end
47
47
 
48
+ class ConnectionError < Error
49
+ attr_reader :original_exception
50
+
51
+ def initialize(message:, original_exception: nil)
52
+ @original_exception = original_exception
53
+ super(message)
54
+ end
55
+ end
56
+
48
57
  class WebhookVerificationError < Error; end
49
58
 
50
59
  class InvalidSignatureError < WebhookVerificationError; end
@@ -4,7 +4,7 @@ require 'faraday'
4
4
 
5
5
  module Lettermint
6
6
  class HttpClient
7
- def initialize(api_token:, base_url:, timeout:)
7
+ def initialize(api_token:, base_url:, timeout:, auth_scheme: :project)
8
8
  normalized_url = "#{base_url.chomp('/')}/"
9
9
  @connection = Faraday.new(url: normalized_url) do |f|
10
10
  f.request :json
@@ -14,7 +14,7 @@ module Lettermint
14
14
  f.headers = {
15
15
  'Content-Type' => 'application/json',
16
16
  'Accept' => 'application/json',
17
- 'x-lettermint-token' => api_token,
17
+ **auth_headers(api_token, auth_scheme),
18
18
  'User-Agent' => "Lettermint/#{Lettermint::VERSION} (Ruby; ruby #{RUBY_VERSION})"
19
19
  }
20
20
  end
@@ -57,13 +57,21 @@ module Lettermint
57
57
 
58
58
  private
59
59
 
60
+ def auth_headers(token, scheme)
61
+ case scheme.to_sym
62
+ when :project then { 'x-lettermint-token' => token }
63
+ when :team then { 'Authorization' => "Bearer #{token}" }
64
+ else raise ArgumentError, "Unknown auth_scheme: #{scheme}"
65
+ end
66
+ end
67
+
60
68
  def with_error_handling
61
69
  response = yield
62
70
  handle_response(response)
63
71
  rescue Faraday::TimeoutError, Timeout::Error
64
72
  raise Lettermint::TimeoutError, "Request timeout after #{@connection.options.timeout}s"
65
73
  rescue Faraday::ConnectionFailed => e
66
- raise Lettermint::Error, e.message
74
+ raise Lettermint::ConnectionError.new(message: e.message, original_exception: e)
67
75
  end
68
76
 
69
77
  def handle_response(response)
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lettermint
4
+ module Resources
5
+ # Base class for Team API resources providing shared functionality.
6
+ class Base
7
+ def initialize(http_client:)
8
+ @http_client = http_client
9
+ end
10
+
11
+ private
12
+
13
+ # Builds query parameters for list endpoints.
14
+ # @param page_size [Integer, nil] Number of items per page
15
+ # @param page_cursor [String, nil] Cursor for pagination
16
+ # @param sort [String, nil] Sort field (prefix with - for descending)
17
+ # @param include [String, nil] Related resources to include
18
+ # @param filters [Hash] Filter parameters (converted to filter[key]=value)
19
+ # @return [Hash, nil] Query parameters hash or nil if empty
20
+ def build_params(page_size: nil, page_cursor: nil, sort: nil, include: nil, **filters)
21
+ params = {
22
+ 'page[size]' => page_size,
23
+ 'page[cursor]' => page_cursor,
24
+ 'sort' => sort,
25
+ 'include' => include
26
+ }.compact
27
+
28
+ filters.each { |k, v| params["filter[#{k}]"] = v unless v.nil? }
29
+ params.empty? ? nil : params
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lettermint
4
+ module Resources
5
+ # Domains resource for managing sending domains and DNS verification.
6
+ class Domains < Base
7
+ # List all domains.
8
+ # @param page_size [Integer, nil] Number of items per page (default: 30)
9
+ # @param page_cursor [String, nil] Cursor for pagination
10
+ # @param sort [String, nil] Sort field: domain, created_at, status_changed_at (prefix - for desc)
11
+ # @param status [String, nil] Filter by status (verified, partially_verified, etc.)
12
+ # @param domain [String, nil] Filter by domain (partial match)
13
+ # @return [Hash] Paginated list of domains
14
+ def list(page_size: nil, page_cursor: nil, sort: nil, status: nil, domain: nil)
15
+ params = build_params(page_size:, page_cursor:, sort:, status:, domain:)
16
+ @http_client.get(path: '/domains', params: params)
17
+ end
18
+
19
+ # Create a new domain.
20
+ # @param domain [String] Domain name (max 255 chars)
21
+ # @return [Hash] Created domain data
22
+ def create(domain:)
23
+ @http_client.post(path: '/domains', data: { domain: domain })
24
+ end
25
+
26
+ # Get domain details.
27
+ # @param id [String] Domain ID
28
+ # @param include [String, nil] Related data to include (dnsRecords, dnsRecordsCount, dnsRecordsExists)
29
+ # @return [Hash] Domain data with optional includes
30
+ def find(id, include: nil)
31
+ params = build_params(include: include)
32
+ @http_client.get(path: "/domains/#{id}", params: params)
33
+ end
34
+
35
+ # Delete a domain.
36
+ # @param id [String] Domain ID
37
+ # @return [Hash] Confirmation message
38
+ def delete(id)
39
+ @http_client.delete(path: "/domains/#{id}")
40
+ end
41
+
42
+ # Verify all DNS records for a domain.
43
+ # @param id [String] Domain ID
44
+ # @return [Hash] Verification result
45
+ def verify_dns(id)
46
+ @http_client.post(path: "/domains/#{id}/dns-records/verify")
47
+ end
48
+
49
+ # Verify a specific DNS record.
50
+ # @param domain_id [String] Domain ID
51
+ # @param record_id [String] DNS record ID
52
+ # @return [Hash] Verification result
53
+ def verify_dns_record(domain_id, record_id)
54
+ @http_client.post(path: "/domains/#{domain_id}/dns-records/#{record_id}/verify")
55
+ end
56
+
57
+ # Update projects associated with a domain.
58
+ # @param id [String] Domain ID
59
+ # @param project_ids [Array<String>] Array of project UUIDs
60
+ # @return [Hash] Updated domain data
61
+ def update_projects(id, project_ids:)
62
+ @http_client.put(path: "/domains/#{id}/projects", data: { project_ids: project_ids })
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lettermint
4
+ module Resources
5
+ # Messages resource for viewing sent and received messages.
6
+ class Messages < Base
7
+ # List messages.
8
+ # @param page_size [Integer, nil] Number of items per page (default: 30)
9
+ # @param page_cursor [String, nil] Cursor for pagination
10
+ # @param sort [String, nil] Sort field: type, status, from_email, subject, created_at, status_changed_at
11
+ # @param type [String, nil] Filter: inbound, outbound
12
+ # @param status [String, nil] Filter by status
13
+ # @param route_id [String, nil] Filter by route ID
14
+ # @param domain_id [String, nil] Filter by domain ID
15
+ # @param tag [String, nil] Filter by tag
16
+ # @param from_email [String, nil] Filter by sender email
17
+ # @param subject [String, nil] Filter by subject
18
+ # @param from_date [String, nil] Filter from date (Y-m-d)
19
+ # @param to_date [String, nil] Filter to date (Y-m-d)
20
+ # @return [Hash] Paginated list of messages
21
+ # rubocop:disable Metrics/ParameterLists
22
+ def list(page_size: nil, page_cursor: nil, sort: nil, type: nil, status: nil,
23
+ route_id: nil, domain_id: nil, tag: nil, from_email: nil, subject: nil,
24
+ from_date: nil, to_date: nil)
25
+ params = build_params(
26
+ page_size: page_size,
27
+ page_cursor: page_cursor,
28
+ sort: sort,
29
+ type: type,
30
+ status: status,
31
+ route_id: route_id,
32
+ domain_id: domain_id,
33
+ tag: tag,
34
+ from_email: from_email,
35
+ subject: subject,
36
+ from_date: from_date,
37
+ to_date: to_date
38
+ )
39
+ @http_client.get(path: '/messages', params: params)
40
+ end
41
+ # rubocop:enable Metrics/ParameterLists
42
+
43
+ # Get message details.
44
+ # @param id [String] Message ID
45
+ # @return [Hash] Message data
46
+ def find(id)
47
+ @http_client.get(path: "/messages/#{id}")
48
+ end
49
+
50
+ # Get message events (delivery history).
51
+ # @param id [String] Message ID
52
+ # @param sort [String, nil] Sort field: timestamp, event
53
+ # @return [Hash] List of message events
54
+ def events(id, sort: nil)
55
+ params = sort ? { 'sort' => sort } : nil
56
+ @http_client.get(path: "/messages/#{id}/events", params: params)
57
+ end
58
+
59
+ # Get raw message source (RFC822 format).
60
+ # @param id [String] Message ID
61
+ # @return [String] Raw message source (message/rfc822)
62
+ def source(id)
63
+ @http_client.get(path: "/messages/#{id}/source", headers: { 'Accept' => 'message/rfc822' })
64
+ end
65
+
66
+ # Get message HTML body.
67
+ # @param id [String] Message ID
68
+ # @return [String] HTML content (text/html)
69
+ def html(id)
70
+ @http_client.get(path: "/messages/#{id}/html", headers: { 'Accept' => 'text/html' })
71
+ end
72
+
73
+ # Get message plain text body.
74
+ # @param id [String] Message ID
75
+ # @return [String] Plain text content (text/plain)
76
+ def text(id)
77
+ @http_client.get(path: "/messages/#{id}/text", headers: { 'Accept' => 'text/plain' })
78
+ end
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lettermint
4
+ module Resources
5
+ # Projects resource for managing projects, members, and accessing routes.
6
+ class Projects < Base
7
+ # List all projects.
8
+ # @param page_size [Integer, nil] Number of items per page (default: 30)
9
+ # @param page_cursor [String, nil] Cursor for pagination
10
+ # @param sort [String, nil] Sort field: name, created_at (prefix - for desc)
11
+ # @param search [String, nil] Search filter
12
+ # @return [Hash] Paginated list of projects
13
+ def list(page_size: nil, page_cursor: nil, sort: nil, search: nil)
14
+ params = build_params(page_size: page_size, page_cursor: page_cursor, sort: sort, search: search)
15
+ @http_client.get(path: '/projects', params: params)
16
+ end
17
+
18
+ # Create a new project.
19
+ # @param name [String] Project name (max 255 chars)
20
+ # @param smtp_enabled [Boolean, nil] Enable SMTP (default: false)
21
+ # @param initial_routes [String, nil] Initial routes: both, transactional, broadcast (default: both)
22
+ # @return [Hash] Created project data including api_token
23
+ def create(name:, smtp_enabled: nil, initial_routes: nil)
24
+ data = { name: name }
25
+ data[:smtp_enabled] = smtp_enabled unless smtp_enabled.nil?
26
+ data[:initial_routes] = initial_routes if initial_routes
27
+ @http_client.post(path: '/projects', data: data)
28
+ end
29
+
30
+ # Get project details.
31
+ #
32
+ # Note: Returns a Hash, not a resource object. To access routes for a project,
33
+ # use `projects.routes(project_id)` rather than chaining on the find result.
34
+ #
35
+ # @param id [String] Project ID
36
+ # @param include [String, nil] Related data: routes, domains, teamMembers, messageStats (+ Count/Exists variants)
37
+ # @return [Hash] Project data with optional includes
38
+ def find(id, include: nil)
39
+ params = include ? { 'include' => include } : nil
40
+ @http_client.get(path: "/projects/#{id}", params: params)
41
+ end
42
+
43
+ # Update a project.
44
+ # @param id [String] Project ID
45
+ # @param name [String, nil] New project name
46
+ # @param smtp_enabled [Boolean, nil] Enable/disable SMTP
47
+ # @param default_route_id [String, nil] Default route UUID
48
+ # @return [Hash] Updated project data
49
+ def update(id, name: nil, smtp_enabled: nil, default_route_id: nil)
50
+ data = {}
51
+ data[:name] = name if name
52
+ data[:smtp_enabled] = smtp_enabled unless smtp_enabled.nil?
53
+ data[:default_route_id] = default_route_id if default_route_id
54
+ @http_client.put(path: "/projects/#{id}", data: data)
55
+ end
56
+
57
+ # Delete a project.
58
+ # @param id [String] Project ID
59
+ # @return [Hash] Confirmation message
60
+ def delete(id)
61
+ @http_client.delete(path: "/projects/#{id}")
62
+ end
63
+
64
+ # Rotate the project API token.
65
+ # @param id [String] Project ID
66
+ # @return [Hash] Contains new_token
67
+ def rotate_token(id)
68
+ @http_client.post(path: "/projects/#{id}/rotate-token")
69
+ end
70
+
71
+ # Update project members (replace all).
72
+ # @param id [String] Project ID
73
+ # @param team_member_ids [Array<String>] Array of team member IDs
74
+ # @return [Hash] Confirmation
75
+ def update_members(id, team_member_ids:)
76
+ @http_client.put(path: "/projects/#{id}/members", data: { team_member_ids: team_member_ids })
77
+ end
78
+
79
+ # Add a member to the project.
80
+ # @param project_id [String] Project ID
81
+ # @param member_id [String] Team member ID
82
+ # @return [Hash] Confirmation
83
+ def add_member(project_id, member_id)
84
+ @http_client.post(path: "/projects/#{project_id}/members/#{member_id}")
85
+ end
86
+
87
+ # Remove a member from the project.
88
+ # @param project_id [String] Project ID
89
+ # @param member_id [String] Team member ID
90
+ # @return [Hash] Confirmation
91
+ def remove_member(project_id, member_id)
92
+ @http_client.delete(path: "/projects/#{project_id}/members/#{member_id}")
93
+ end
94
+
95
+ # Get a routes accessor scoped to this project.
96
+ # @param project_id [String] Project ID
97
+ # @return [Routes] Routes resource scoped to the project
98
+ def routes(project_id)
99
+ Routes.new(http_client: @http_client, project_id: project_id)
100
+ end
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lettermint
4
+ module Resources
5
+ # Routes resource for managing project routes (transactional, broadcast, inbound).
6
+ # Can be instantiated with a project_id for scoped operations or without for direct route access.
7
+ class Routes < Base
8
+ # @param http_client [HttpClient] HTTP client instance
9
+ # @param project_id [String, nil] Optional project ID for scoped operations
10
+ def initialize(http_client:, project_id: nil)
11
+ super(http_client: http_client)
12
+ @project_id = project_id
13
+ end
14
+
15
+ # List routes for a project.
16
+ # Requires project_id to be set (via constructor or projects.routes(id)).
17
+ # @param page_size [Integer, nil] Number of items per page (default: 30)
18
+ # @param page_cursor [String, nil] Cursor for pagination
19
+ # @param sort [String, nil] Sort field: name, slug, created_at (prefix - for desc)
20
+ # @param route_type [String, nil] Filter: transactional, broadcast, inbound
21
+ # @param is_default [Boolean, nil] Filter by default route status
22
+ # @param search [String, nil] Search filter
23
+ # @return [Hash] Paginated list of routes
24
+ # rubocop:disable Metrics/ParameterLists
25
+ def list(page_size: nil, page_cursor: nil, sort: nil, route_type: nil,
26
+ is_default: nil, search: nil)
27
+ raise ArgumentError, 'project_id required for listing routes' unless @project_id
28
+
29
+ params = build_params(
30
+ page_size: page_size,
31
+ page_cursor: page_cursor,
32
+ sort: sort,
33
+ route_type: route_type,
34
+ is_default: is_default,
35
+ search: search
36
+ )
37
+ @http_client.get(path: "/projects/#{@project_id}/routes", params: params)
38
+ end
39
+ # rubocop:enable Metrics/ParameterLists
40
+
41
+ # Create a new route in a project.
42
+ # Requires project_id to be set.
43
+ # @param name [String] Route name (max 255 chars)
44
+ # @param route_type [String] Type: transactional, broadcast, inbound
45
+ # @param slug [String, nil] Optional slug (max 255 chars)
46
+ # @return [Hash] Created route data
47
+ def create(name:, route_type:, slug: nil)
48
+ raise ArgumentError, 'project_id required for creating routes' unless @project_id
49
+
50
+ data = { name: name, route_type: route_type }
51
+ data[:slug] = slug if slug
52
+ @http_client.post(path: "/projects/#{@project_id}/routes", data: data)
53
+ end
54
+
55
+ # Get route details.
56
+ # @param id [String] Route ID
57
+ # @param include [String, nil] Related data: project, statistics
58
+ # @return [Hash] Route data
59
+ def find(id, include: nil)
60
+ params = include ? { 'include' => include } : nil
61
+ @http_client.get(path: "/routes/#{id}", params: params)
62
+ end
63
+
64
+ # Update a route.
65
+ # @param id [String] Route ID
66
+ # @param name [String, nil] New route name
67
+ # @param settings [Hash, nil] Route settings (track_opens, track_clicks, disable_hosted_unsubscribe)
68
+ # @param inbound_settings [Hash, nil] Inbound settings (inbound_domain, spam_threshold, etc.)
69
+ # @return [Hash] Updated route data
70
+ def update(id, name: nil, settings: nil, inbound_settings: nil)
71
+ data = {}
72
+ data[:name] = name if name
73
+ data[:settings] = settings if settings
74
+ data[:inbound_settings] = inbound_settings if inbound_settings
75
+ @http_client.put(path: "/routes/#{id}", data: data)
76
+ end
77
+
78
+ # Delete a route.
79
+ # @param id [String] Route ID
80
+ # @return [Hash] Confirmation message
81
+ def delete(id)
82
+ @http_client.delete(path: "/routes/#{id}")
83
+ end
84
+
85
+ # Verify inbound domain for a route.
86
+ # @param id [String] Route ID
87
+ # @return [Hash] Verification result
88
+ def verify_inbound_domain(id)
89
+ @http_client.post(path: "/routes/#{id}/verify-inbound-domain")
90
+ end
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lettermint
4
+ module Resources
5
+ # Stats resource for retrieving email statistics.
6
+ class Stats < Base
7
+ # Get statistics for a date range.
8
+ # @param from [String] Start date (Y-m-d format, required)
9
+ # @param to [String] End date (Y-m-d format, required, max 90 days from start)
10
+ # @param project_id [String, nil] Filter by project ID
11
+ # @param route_id [String, nil] Filter by a single route ID
12
+ # @param route_ids [Array<String>, String, nil] Filter by multiple route IDs
13
+ # @return [Hash] Stats data with totals and daily breakdown
14
+ def get(from:, to:, project_id: nil, route_id: nil, route_ids: nil)
15
+ params = { 'from' => from, 'to' => to }
16
+ params['project_id'] = project_id if project_id
17
+ params['route_id'] = route_id if route_id
18
+ params['route_ids'] = route_ids if route_ids
19
+ @http_client.get(path: '/stats', params: params)
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lettermint
4
+ module Resources
5
+ # Suppressions resource for managing email suppression lists.
6
+ class Suppressions < Base
7
+ # List suppressions.
8
+ # @param page_size [Integer, nil] Number of items per page (default: 30)
9
+ # @param page_cursor [String, nil] Cursor for pagination
10
+ # @param sort [String, nil] Sort field: value, created_at, reason
11
+ # @param scope [String, nil] Filter: team, project, route
12
+ # @param route_id [String, nil] Filter by route ID
13
+ # @param project_id [String, nil] Filter by project ID
14
+ # @param value [String, nil] Filter by suppression value (email/domain/extension)
15
+ # @param reason [String, nil] Filter: spam_complaint, hard_bounce, unsubscribe, manual
16
+ # @return [Hash] Paginated list of suppressions
17
+ # rubocop:disable Metrics/ParameterLists
18
+ def list(page_size: nil, page_cursor: nil, sort: nil, scope: nil,
19
+ route_id: nil, project_id: nil, value: nil, reason: nil)
20
+ params = build_params(
21
+ page_size: page_size,
22
+ page_cursor: page_cursor,
23
+ sort: sort,
24
+ scope: scope,
25
+ route_id: route_id,
26
+ project_id: project_id,
27
+ value: value,
28
+ reason: reason
29
+ )
30
+ @http_client.get(path: '/suppressions', params: params)
31
+ end
32
+ # rubocop:enable Metrics/ParameterLists
33
+
34
+ # Create a suppression entry.
35
+ # @param reason [String] Reason: spam_complaint, hard_bounce, unsubscribe, manual
36
+ # @param scope [String] Scope: team, project, route
37
+ # @param email [String, nil] Single email to suppress (max 255 chars)
38
+ # @param emails [Array<String>, nil] Multiple emails to suppress (max 1000)
39
+ # @param route_id [String, nil] Route ID (required if scope is route)
40
+ # @param project_id [String, nil] Project ID (required if scope is project)
41
+ # @return [Hash] Created suppression data
42
+ def create(reason:, scope:, email: nil, emails: nil, route_id: nil, project_id: nil) # rubocop:disable Metrics/ParameterLists
43
+ data = { reason: reason, scope: scope }
44
+ data[:email] = email if email
45
+ data[:emails] = emails if emails
46
+ data[:route_id] = route_id if route_id
47
+ data[:project_id] = project_id if project_id
48
+ @http_client.post(path: '/suppressions', data: data)
49
+ end
50
+
51
+ # Delete a suppression entry.
52
+ # @param id [String] Suppression ID
53
+ # @return [Hash] Confirmation message
54
+ def delete(id)
55
+ @http_client.delete(path: "/suppressions/#{id}")
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lettermint
4
+ module Resources
5
+ # Team resource for managing team settings, usage, and members.
6
+ class Team < Base
7
+ # Get team details.
8
+ # @param include [String, nil] Related data to include (features, featuresCount, featuresExists)
9
+ # @return [Hash] Team data
10
+ def get(include: nil)
11
+ params = include ? { 'include' => include } : nil
12
+ @http_client.get(path: '/team', params: params)
13
+ end
14
+
15
+ # Update team settings.
16
+ # @param name [String] New team name (max 255 chars)
17
+ # @return [Hash] Updated team data
18
+ def update(name:)
19
+ @http_client.put(path: '/team', data: { name: name })
20
+ end
21
+
22
+ # Get team usage statistics.
23
+ # @return [Hash] Current period and up to 12 historical periods
24
+ def usage
25
+ @http_client.get(path: '/team/usage')
26
+ end
27
+
28
+ # List team members.
29
+ # @param page_size [Integer, nil] Number of items per page (default: 30)
30
+ # @param page_cursor [String, nil] Cursor for pagination
31
+ # @return [Hash] Paginated list of team members
32
+ def members(page_size: nil, page_cursor: nil)
33
+ params = build_params(page_size: page_size, page_cursor: page_cursor)
34
+ @http_client.get(path: '/team/members', params: params)
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,124 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lettermint
4
+ module Resources
5
+ # Webhooks resource for managing webhook endpoints and viewing deliveries.
6
+ class Webhooks < Base
7
+ # List all webhooks.
8
+ # @param page_size [Integer, nil] Number of items per page (default: 30)
9
+ # @param page_cursor [String, nil] Cursor for pagination
10
+ # @param sort [String, nil] Sort field: name, url, created_at (prefix - for desc)
11
+ # @param enabled [Boolean, nil] Filter by enabled status
12
+ # @param event [String, nil] Filter by event type
13
+ # @param route_id [String, nil] Filter by route ID
14
+ # @param search [String, nil] Search filter
15
+ # @return [Hash] Paginated list of webhooks
16
+ # rubocop:disable Metrics/ParameterLists
17
+ def list(page_size: nil, page_cursor: nil, sort: nil, enabled: nil,
18
+ event: nil, route_id: nil, search: nil)
19
+ params = build_params(
20
+ page_size: page_size,
21
+ page_cursor: page_cursor,
22
+ sort: sort,
23
+ enabled: enabled,
24
+ event: event,
25
+ route_id: route_id,
26
+ search: search
27
+ )
28
+ @http_client.get(path: '/webhooks', params: params)
29
+ end
30
+ # rubocop:enable Metrics/ParameterLists
31
+
32
+ # Create a new webhook.
33
+ # @param route_id [String] Route ID to attach webhook to
34
+ # @param name [String] Webhook name (max 255 chars)
35
+ # @param url [String] Webhook URL (max 500 chars)
36
+ # @param events [Array<String>] Event types to subscribe (min 1)
37
+ # @param enabled [Boolean, nil] Enable webhook (default: true)
38
+ # @return [Hash] Created webhook data including secret (shown only once)
39
+ def create(route_id:, name:, url:, events:, enabled: nil)
40
+ data = { route_id: route_id, name: name, url: url, events: events }
41
+ data[:enabled] = enabled unless enabled.nil?
42
+ @http_client.post(path: '/webhooks', data: data)
43
+ end
44
+
45
+ # Get webhook details.
46
+ # @param id [String] Webhook ID
47
+ # @return [Hash] Webhook data including secret
48
+ def find(id)
49
+ @http_client.get(path: "/webhooks/#{id}")
50
+ end
51
+
52
+ # Update a webhook.
53
+ # @param id [String] Webhook ID
54
+ # @param name [String, nil] New webhook name
55
+ # @param url [String, nil] New webhook URL
56
+ # @param enabled [Boolean, nil] Enable/disable webhook
57
+ # @param events [Array<String>, nil] Event types (min 1)
58
+ # @return [Hash] Updated webhook data
59
+ def update(id, name: nil, url: nil, enabled: nil, events: nil)
60
+ data = {}
61
+ data[:name] = name if name
62
+ data[:url] = url if url
63
+ data[:enabled] = enabled unless enabled.nil?
64
+ data[:events] = events if events
65
+ @http_client.put(path: "/webhooks/#{id}", data: data)
66
+ end
67
+
68
+ # Delete a webhook.
69
+ # @param id [String] Webhook ID
70
+ # @return [Hash] Confirmation message
71
+ def delete(id)
72
+ @http_client.delete(path: "/webhooks/#{id}")
73
+ end
74
+
75
+ # Test a webhook by sending a test delivery.
76
+ # @param id [String] Webhook ID
77
+ # @return [Hash] Contains delivery_id for tracking
78
+ def test(id)
79
+ @http_client.post(path: "/webhooks/#{id}/test")
80
+ end
81
+
82
+ # Regenerate webhook secret.
83
+ # @param id [String] Webhook ID
84
+ # @return [Hash] Updated webhook data with new secret
85
+ def regenerate_secret(id)
86
+ @http_client.post(path: "/webhooks/#{id}/regenerate-secret")
87
+ end
88
+
89
+ # List webhook deliveries.
90
+ # @param webhook_id [String] Webhook ID
91
+ # @param page_size [Integer, nil] Number of items per page
92
+ # @param page_cursor [String, nil] Cursor for pagination
93
+ # @param sort [String, nil] Sort field: created_at, attempt_number
94
+ # @param status [String, nil] Filter: pending, success, failed, client_error, server_error, timeout
95
+ # @param event_type [String, nil] Filter by event type
96
+ # @param from_date [String, nil] Filter from date (Y-m-d)
97
+ # @param to_date [String, nil] Filter to date (Y-m-d)
98
+ # @return [Hash] Paginated list of deliveries
99
+ # rubocop:disable Metrics/ParameterLists
100
+ def deliveries(webhook_id, page_size: nil, page_cursor: nil, sort: nil,
101
+ status: nil, event_type: nil, from_date: nil, to_date: nil)
102
+ params = build_params(
103
+ page_size: page_size,
104
+ page_cursor: page_cursor,
105
+ sort: sort,
106
+ status: status,
107
+ event_type: event_type,
108
+ from_date: from_date,
109
+ to_date: to_date
110
+ )
111
+ @http_client.get(path: "/webhooks/#{webhook_id}/deliveries", params: params)
112
+ end
113
+ # rubocop:enable Metrics/ParameterLists
114
+
115
+ # Get a specific delivery.
116
+ # @param webhook_id [String] Webhook ID
117
+ # @param delivery_id [String] Delivery ID
118
+ # @return [Hash] Delivery data including payload and response
119
+ def delivery(webhook_id, delivery_id)
120
+ @http_client.get(path: "/webhooks/#{webhook_id}/deliveries/#{delivery_id}")
121
+ end
122
+ end
123
+ end
124
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lettermint
4
+ # Client for the Lettermint Sending API (project-level email sending).
5
+ # Authenticates with project tokens via x-lettermint-token header.
6
+ class SendingAPI
7
+ attr_reader :configuration
8
+
9
+ def initialize(api_token:, base_url: nil, timeout: nil)
10
+ validate_api_token!(api_token)
11
+
12
+ @configuration = Configuration.new
13
+ @configuration.base_url = base_url || Lettermint.configuration.base_url
14
+ @configuration.timeout = timeout || Lettermint.configuration.timeout
15
+
16
+ yield @configuration if block_given?
17
+
18
+ @http_client = HttpClient.new(
19
+ api_token: api_token,
20
+ base_url: @configuration.base_url,
21
+ timeout: @configuration.timeout,
22
+ auth_scheme: :project
23
+ )
24
+ end
25
+
26
+ def email
27
+ EmailMessage.new(http_client: @http_client)
28
+ end
29
+
30
+ # Makes a GET request to an arbitrary API endpoint.
31
+ #
32
+ # @param path [String] The API endpoint path (e.g., '/domains')
33
+ # @param params [Hash, nil] Query parameters to include in the request
34
+ # @param headers [Hash, nil] Additional HTTP headers
35
+ # @return [Hash] The parsed JSON response body
36
+ def get(path, params: nil, headers: nil)
37
+ @http_client.get(path: path, params: params, headers: headers)
38
+ end
39
+
40
+ # Makes a POST request to an arbitrary API endpoint.
41
+ #
42
+ # @param path [String] The API endpoint path
43
+ # @param data [Hash, nil] The request body (will be JSON-encoded)
44
+ # @param headers [Hash, nil] Additional HTTP headers
45
+ # @return [Hash] The parsed JSON response body
46
+ def post(path, data: nil, headers: nil)
47
+ @http_client.post(path: path, data: data, headers: headers)
48
+ end
49
+
50
+ # Makes a PUT request to an arbitrary API endpoint.
51
+ #
52
+ # @param path [String] The API endpoint path
53
+ # @param data [Hash, nil] The request body (will be JSON-encoded)
54
+ # @param headers [Hash, nil] Additional HTTP headers
55
+ # @return [Hash] The parsed JSON response body
56
+ def put(path, data: nil, headers: nil)
57
+ @http_client.put(path: path, data: data, headers: headers)
58
+ end
59
+
60
+ # Makes a DELETE request to an arbitrary API endpoint.
61
+ #
62
+ # @param path [String] The API endpoint path
63
+ # @param headers [Hash, nil] Additional HTTP headers
64
+ # @return [Hash] The parsed JSON response body
65
+ def delete(path, headers: nil)
66
+ @http_client.delete(path: path, headers: headers)
67
+ end
68
+
69
+ private
70
+
71
+ def validate_api_token!(token)
72
+ raise ArgumentError, 'API token cannot be empty' if token.nil? || token.to_s.strip.empty?
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lettermint
4
+ # Client for the Lettermint Team API (team-level management operations).
5
+ # Authenticates with team tokens (lm_team_*) via Authorization: Bearer header.
6
+ class TeamAPI
7
+ attr_reader :configuration
8
+
9
+ def initialize(team_token:, base_url: nil, timeout: nil)
10
+ validate_team_token!(team_token)
11
+
12
+ @configuration = Configuration.new
13
+ @configuration.base_url = base_url || Lettermint.configuration.base_url
14
+ @configuration.timeout = timeout || Lettermint.configuration.timeout
15
+
16
+ yield @configuration if block_given?
17
+
18
+ @http_client = HttpClient.new(
19
+ api_token: team_token,
20
+ base_url: @configuration.base_url,
21
+ timeout: @configuration.timeout,
22
+ auth_scheme: :team
23
+ )
24
+ end
25
+
26
+ # Health check endpoint (accepts both token types)
27
+ # @return [Hash] Parsed response body, e.g. { 'ok' => true } on success
28
+ def ping
29
+ @http_client.get(path: '/ping')
30
+ end
31
+
32
+ # Team resource accessor
33
+ # @return [Resources::Team]
34
+ def team
35
+ Resources::Team.new(http_client: @http_client)
36
+ end
37
+
38
+ # Domains resource accessor
39
+ # @return [Resources::Domains]
40
+ def domains
41
+ Resources::Domains.new(http_client: @http_client)
42
+ end
43
+
44
+ # Projects resource accessor
45
+ # @return [Resources::Projects]
46
+ def projects
47
+ Resources::Projects.new(http_client: @http_client)
48
+ end
49
+
50
+ # Webhooks resource accessor
51
+ # @return [Resources::Webhooks]
52
+ def webhooks
53
+ Resources::Webhooks.new(http_client: @http_client)
54
+ end
55
+
56
+ # Messages resource accessor
57
+ # @return [Resources::Messages]
58
+ def messages
59
+ Resources::Messages.new(http_client: @http_client)
60
+ end
61
+
62
+ # Suppressions resource accessor
63
+ # @return [Resources::Suppressions]
64
+ def suppressions
65
+ Resources::Suppressions.new(http_client: @http_client)
66
+ end
67
+
68
+ # Stats resource accessor
69
+ # @return [Resources::Stats]
70
+ def stats
71
+ Resources::Stats.new(http_client: @http_client)
72
+ end
73
+
74
+ # Routes resource accessor (top-level for direct route access)
75
+ # For project-scoped routes, use projects.routes(project_id)
76
+ # @return [Resources::Routes]
77
+ def routes
78
+ Resources::Routes.new(http_client: @http_client)
79
+ end
80
+
81
+ private
82
+
83
+ def validate_team_token!(token)
84
+ raise ArgumentError, 'Team token cannot be empty' if token.nil? || token.to_s.strip.empty?
85
+ return if token.to_s.start_with?('lm_team_')
86
+
87
+ raise ArgumentError, "Invalid team token format (expected 'lm_team_*')"
88
+ end
89
+ end
90
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Lettermint
4
- VERSION = '0.1.0'
4
+ VERSION = '0.2.0'
5
5
  end
data/lib/lettermint.rb CHANGED
@@ -7,6 +7,21 @@ require_relative 'lettermint/configuration'
7
7
  require_relative 'lettermint/http_client'
8
8
  require_relative 'lettermint/email_message'
9
9
  require_relative 'lettermint/webhook'
10
+
11
+ # Resources (Team API)
12
+ require_relative 'lettermint/resources/base'
13
+ require_relative 'lettermint/resources/team'
14
+ require_relative 'lettermint/resources/domains'
15
+ require_relative 'lettermint/resources/projects'
16
+ require_relative 'lettermint/resources/routes'
17
+ require_relative 'lettermint/resources/webhooks'
18
+ require_relative 'lettermint/resources/messages'
19
+ require_relative 'lettermint/resources/suppressions'
20
+ require_relative 'lettermint/resources/stats'
21
+
22
+ # API Clients
23
+ require_relative 'lettermint/sending_api'
24
+ require_relative 'lettermint/team_api'
10
25
  require_relative 'lettermint/client'
11
26
 
12
27
  module Lettermint
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: lettermint
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Delano
@@ -31,6 +31,7 @@ executables: []
31
31
  extensions: []
32
32
  extra_rdoc_files: []
33
33
  files:
34
+ - ".gemini/settings.json"
34
35
  - CHANGELOG.md
35
36
  - LICENSE
36
37
  - README.md
@@ -41,6 +42,17 @@ files:
41
42
  - lib/lettermint/email_message.rb
42
43
  - lib/lettermint/errors.rb
43
44
  - lib/lettermint/http_client.rb
45
+ - lib/lettermint/resources/base.rb
46
+ - lib/lettermint/resources/domains.rb
47
+ - lib/lettermint/resources/messages.rb
48
+ - lib/lettermint/resources/projects.rb
49
+ - lib/lettermint/resources/routes.rb
50
+ - lib/lettermint/resources/stats.rb
51
+ - lib/lettermint/resources/suppressions.rb
52
+ - lib/lettermint/resources/team.rb
53
+ - lib/lettermint/resources/webhooks.rb
54
+ - lib/lettermint/sending_api.rb
55
+ - lib/lettermint/team_api.rb
44
56
  - lib/lettermint/types.rb
45
57
  - lib/lettermint/version.rb
46
58
  - lib/lettermint/webhook.rb