laneful-ruby 1.1.1 → 1.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: 4cebf80375fe3b41ce712f115c4e291c6f7ff0a9ceb4cf90de829422966b970b
4
- data.tar.gz: 7fee98bf4a843ffe95123f1c544545760fd4f943651199619b93450e37c7727e
3
+ metadata.gz: ed5ff94086f558d6ea4bad6c914ec22cbc7b8d012cc27fc3d385d3cbc05c5c49
4
+ data.tar.gz: 02a6ba4d5c4dbee58809d4e15214623fc273770b3779d73fe6ae25b61de75252
5
5
  SHA512:
6
- metadata.gz: 11e4cea266db6d53a73958d9bf4b6a4106c31fe1cb7bcba62f52959444fc9c70d2b2432906bb4c540365b0edbe76af09d4f55a80f84a1f16445595d0f8242cce
7
- data.tar.gz: 0f94b21d5267eb709cbada7cca3225f2eb4a6cebdfa694a2d63caec3d2fc7a7bf3f375e564cbedec8632fd08c91b0546a883175a9d591661c759bf562de82270
6
+ metadata.gz: cc30d8a4112104938f451247b7c24654365f4bb6f8efb5dbeacff4947b00841115563bc7e93d4e36b2eb17e3f115a3616d91a99ebff081d0f322fc9ebdca2060
7
+ data.tar.gz: 560628e6bd7d5cefa3b9e3229c4be1806d8703d6a7c61e919b94d386b9b3e4a27f1ea95602096e5783236489f4c234233676ebeadf42591eb3db767ec4cf6875
@@ -1,13 +1,21 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'cgi'
4
+ require 'json'
5
+
3
6
  module Laneful
4
- # Main client for communicating with the Laneful email API
7
+ # Main client for communicating with the Laneful API.
8
+ # Email sending uses a send host (https://your-endpoint.send.laneful.net).
9
+ # Domain, unsubscribe-group, and analytics endpoints use the organization
10
+ # API host (https://api.laneful.net).
5
11
  class Client
6
12
  include HTTParty
7
13
 
8
- # Default timeout in seconds
9
14
  DEFAULT_TIMEOUT = 30
10
15
 
16
+ # Repeat array keys as workspace_ids=1&workspace_ids=2 (not workspace_ids[]=).
17
+ query_string_normalizer HTTParty::Request::NON_RAILS_QUERY_STRING_NORMALIZER
18
+
11
19
  attr_reader :base_url, :auth_token, :timeout
12
20
 
13
21
  def initialize(base_url, auth_token, timeout: DEFAULT_TIMEOUT)
@@ -16,26 +24,112 @@ module Laneful
16
24
  @timeout = timeout
17
25
 
18
26
  validate_configuration!
19
-
20
- # Configure HTTParty
21
- self.class.base_uri @base_url
22
- self.class.default_timeout @timeout
23
- self.class.headers default_headers
24
27
  end
25
28
 
26
29
  # Sends a single email
27
- def send_email(email)
28
- send_emails([email])
30
+ def send_email(email, mail_settings = nil)
31
+ send_emails([email], mail_settings)
29
32
  end
30
33
 
31
34
  # Sends multiple emails
32
- def send_emails(emails)
35
+ def send_emails(emails, mail_settings = nil)
33
36
  validate_emails!(emails)
34
37
 
35
38
  request_data = { 'emails' => emails.map(&:to_hash) }
36
- response = self.class.post("/#{API_VERSION}/email/send", body: request_data.to_json)
39
+ request_data['mail_settings'] = mail_settings.to_hash if mail_settings
37
40
 
38
- handle_response(response)
41
+ request('POST', '/email/send', request_data)
42
+ end
43
+
44
+ # List unsubscribe groups for a workspace.
45
+ # Uses the organization API host (https://api.laneful.net).
46
+ def list_unsubscribe_groups(workspace_id, params = nil)
47
+ ListUnsubscribeGroupsResponse.from_hash(
48
+ request('GET', "/workspaces/#{workspace_id}/unsubscribe-groups", nil, params&.to_query)
49
+ )
50
+ end
51
+
52
+ # Create an unsubscribe group in a workspace.
53
+ def create_unsubscribe_group(workspace_id, name)
54
+ data = request('POST', "/workspaces/#{workspace_id}/unsubscribe-groups", { 'name' => name })
55
+ UnsubscribeGroup.from_hash(data['unsubscribe_group'] || data)
56
+ end
57
+
58
+ # Update an unsubscribe group.
59
+ def update_unsubscribe_group(workspace_id, unsubscribe_group_id, name)
60
+ data = request(
61
+ 'PATCH',
62
+ "/workspaces/#{workspace_id}/unsubscribe-groups/#{unsubscribe_group_id}",
63
+ { 'name' => name }
64
+ )
65
+ UnsubscribeGroup.from_hash(data['unsubscribe_group'] || data)
66
+ end
67
+
68
+ # List sending domains for a workspace.
69
+ def list_domains(workspace_id, params = nil)
70
+ ListDomainsResponse.from_hash(
71
+ request('GET', "/workspaces/#{workspace_id}/domains", nil, params&.to_query)
72
+ )
73
+ end
74
+
75
+ # Get a single sending domain by name.
76
+ def get_domain(workspace_id, domain)
77
+ Domain.from_hash(
78
+ request('GET', "/workspaces/#{workspace_id}/domains/#{encode_path(domain)}")
79
+ )
80
+ end
81
+
82
+ # Create a sending domain in a workspace.
83
+ def create_domain(workspace_id, create_request)
84
+ Domain.from_hash(
85
+ request('POST', "/workspaces/#{workspace_id}/domains", create_request.to_hash)
86
+ )
87
+ end
88
+
89
+ # Update a domain's mutable settings (currently the email track).
90
+ def update_domain(workspace_id, domain, update_request)
91
+ Domain.from_hash(
92
+ request(
93
+ 'PATCH',
94
+ "/workspaces/#{workspace_id}/domains/#{encode_path(domain)}",
95
+ update_request.to_hash
96
+ )
97
+ )
98
+ end
99
+
100
+ # Trigger DNS verification for a domain.
101
+ def verify_domain(workspace_id, domain)
102
+ Domain.from_hash(
103
+ request('POST', "/workspaces/#{workspace_id}/domains/#{encode_path(domain)}/verify")
104
+ )
105
+ end
106
+
107
+ # Delete a sending domain from a workspace.
108
+ def delete_domain(workspace_id, domain)
109
+ SuccessResponse.from_hash(
110
+ request('DELETE', "/workspaces/#{workspace_id}/domains/#{encode_path(domain)}")
111
+ )
112
+ end
113
+
114
+ # List domains whose spam complaint ratio reached a critical level.
115
+ def list_domain_spam_ratio_radar(params = nil)
116
+ ListDomainSpamRatioRadarResponse.from_hash(
117
+ request('GET', '/analytics/radar/domain-spam-ratio', nil, params&.to_query)
118
+ )
119
+ end
120
+
121
+ # List daily Google Postmaster Tools spam-rate reports.
122
+ def list_google_postmaster_spam_reports(params = nil)
123
+ ListGooglePostmasterSpamReportsResponse.from_hash(
124
+ request('GET', '/analytics/google-postmaster/spam-reports', nil, params&.to_query)
125
+ )
126
+ end
127
+
128
+ # List daily Microsoft SNDS reports for the organization's sending IPs.
129
+ def list_snds_reports(params = nil)
130
+ ListSndsReportsResponse.from_hash(
131
+ request('GET', '/analytics/microsoft-snds/reports', nil, params&.to_query)
132
+ )
39
133
  end
40
134
 
41
135
  private
@@ -45,7 +139,6 @@ module Laneful
45
139
 
46
140
  raise ValidationException, 'Auth token cannot be empty' if auth_token.nil? || auth_token.empty?
47
141
 
48
- # Basic URL validation
49
142
  return if base_url.match?(%r{^https?://})
50
143
 
51
144
  raise ValidationException, 'Base URL must be a valid HTTP/HTTPS URL'
@@ -59,18 +152,46 @@ module Laneful
59
152
  end
60
153
  end
61
154
 
62
- def default_headers
63
- {
155
+ def default_headers(with_json: true)
156
+ headers = {
64
157
  'Authorization' => "Bearer #{auth_token}",
65
- 'Content-Type' => 'application/json',
66
158
  'Accept' => 'application/json',
67
159
  'User-Agent' => USER_AGENT
68
160
  }
161
+ headers['Content-Type'] = 'application/json' if with_json
162
+ headers
163
+ end
164
+
165
+ def build_url(path)
166
+ "#{base_url.chomp('/')}/#{API_VERSION}/#{path.sub(%r{\A/}, '')}"
167
+ end
168
+
169
+ def encode_path(value)
170
+ CGI.escape(value.to_s).gsub('+', '%20')
171
+ end
172
+
173
+ def request(method, path, json = nil, query = nil)
174
+ url = build_url(path)
175
+
176
+ options = {
177
+ headers: default_headers(with_json: !json.nil?),
178
+ timeout: timeout
179
+ }
180
+ options[:query] = query unless query.nil? || query.empty?
181
+ options[:body] = json.to_json unless json.nil?
182
+
183
+ begin
184
+ response = self.class.send(method.downcase, url, options)
185
+ rescue StandardError => e
186
+ raise HttpException.new("HTTP request failed: #{e.message}", 0, e)
187
+ end
188
+
189
+ handle_response(response)
69
190
  end
70
191
 
71
192
  def handle_response(response)
72
193
  case response.code
73
- when 200, 201, 202
194
+ when 200, 201, 202, 204
74
195
  parse_success_response(response)
75
196
  when 404
76
197
  raise HttpException.new(
@@ -59,12 +59,15 @@ module Laneful
59
59
 
60
60
  # Configuration for email tracking settings
61
61
  class TrackingSettings
62
- attr_reader :opens, :clicks, :unsubscribes
62
+ attr_reader :opens, :clicks, :unsubscribes, :unsubscribe_group_id, :unsubscribe_group_name
63
63
 
64
- def initialize(opens: false, clicks: false, unsubscribes: false)
64
+ def initialize(opens: false, clicks: false, unsubscribes: false,
65
+ unsubscribe_group_id: nil, unsubscribe_group_name: nil)
65
66
  @opens = opens
66
67
  @clicks = clicks
67
68
  @unsubscribes = unsubscribes
69
+ @unsubscribe_group_id = unsubscribe_group_id
70
+ @unsubscribe_group_name = unsubscribe_group_name
68
71
  end
69
72
 
70
73
  # Creates tracking settings from a hash representation
@@ -72,22 +75,31 @@ module Laneful
72
75
  new(
73
76
  opens: data['opens'] || false,
74
77
  clicks: data['clicks'] || false,
75
- unsubscribes: data['unsubscribes'] || false
78
+ unsubscribes: data['unsubscribes'] || false,
79
+ unsubscribe_group_id: data['unsubscribe_group_id'],
80
+ unsubscribe_group_name: data['unsubscribe_group_name']
76
81
  )
77
82
  end
78
83
 
79
84
  def to_hash
80
- {
85
+ hash = {
81
86
  'opens' => opens,
82
87
  'clicks' => clicks,
83
88
  'unsubscribes' => unsubscribes
84
89
  }
90
+ hash['unsubscribe_group_id'] = unsubscribe_group_id unless unsubscribe_group_id.nil?
91
+ hash['unsubscribe_group_name'] = unsubscribe_group_name unless unsubscribe_group_name.nil?
92
+ hash
85
93
  end
86
94
 
87
95
  def ==(other)
88
96
  return false unless other.is_a?(TrackingSettings)
89
97
 
90
- opens == other.opens && clicks == other.clicks && unsubscribes == other.unsubscribes
98
+ opens == other.opens &&
99
+ clicks == other.clicks &&
100
+ unsubscribes == other.unsubscribes &&
101
+ unsubscribe_group_id == other.unsubscribe_group_id &&
102
+ unsubscribe_group_name == other.unsubscribe_group_name
91
103
  end
92
104
 
93
105
  def eql?(other)
@@ -95,7 +107,7 @@ module Laneful
95
107
  end
96
108
 
97
109
  def hash
98
- [opens, clicks, unsubscribes].hash
110
+ [opens, clicks, unsubscribes, unsubscribe_group_id, unsubscribe_group_name].hash
99
111
  end
100
112
 
101
113
  def to_s
@@ -103,14 +115,53 @@ module Laneful
103
115
  end
104
116
  end
105
117
 
118
+ # Request-level mail settings (sandbox mode, return message IDs)
119
+ class MailSettings
120
+ attr_reader :sandbox_mode, :return_message_ids
121
+
122
+ def initialize(sandbox_mode: nil, return_message_ids: nil)
123
+ @sandbox_mode = sandbox_mode
124
+ @return_message_ids = return_message_ids
125
+ end
126
+
127
+ def self.from_hash(data)
128
+ new(
129
+ sandbox_mode: data['sandbox_mode'],
130
+ return_message_ids: data['return_message_ids']
131
+ )
132
+ end
133
+
134
+ def to_hash
135
+ hash = {}
136
+ hash['sandbox_mode'] = sandbox_mode unless sandbox_mode.nil?
137
+ hash['return_message_ids'] = return_message_ids unless return_message_ids.nil?
138
+ hash
139
+ end
140
+
141
+ def ==(other)
142
+ return false unless other.is_a?(MailSettings)
143
+
144
+ sandbox_mode == other.sandbox_mode && return_message_ids == other.return_message_ids
145
+ end
146
+
147
+ def eql?(other)
148
+ self == other
149
+ end
150
+
151
+ def hash
152
+ [sandbox_mode, return_message_ids].hash
153
+ end
154
+ end
155
+
106
156
  # Represents a file attachment for an email
107
157
  class Attachment
108
- attr_reader :filename, :content_type, :content
158
+ attr_reader :filename, :content_type, :content, :inline_id
109
159
 
110
- def initialize(filename, content_type, content)
160
+ def initialize(filename, content_type, content, inline_id = nil)
111
161
  @filename = filename
112
162
  @content_type = content_type
113
163
  @content = content
164
+ @inline_id = inline_id
114
165
  validate!
115
166
  end
116
167
 
@@ -124,22 +175,27 @@ module Laneful
124
175
 
125
176
  # Creates an attachment from a hash representation
126
177
  def self.from_hash(data)
127
- filename = data['file_name'] || data['filename'] # Support both field names
128
- new(filename, data['content_type'], data['content'])
178
+ filename = data['file_name'] || data['filename']
179
+ new(filename, data['content_type'], data['content'], data['inline_id'])
129
180
  end
130
181
 
131
182
  def to_hash
132
- {
183
+ hash = {
133
184
  'file_name' => filename,
134
185
  'content_type' => content_type,
135
186
  'content' => content
136
187
  }
188
+ hash['inline_id'] = inline_id if inline_id && !inline_id.empty?
189
+ hash
137
190
  end
138
191
 
139
192
  def ==(other)
140
193
  return false unless other.is_a?(Attachment)
141
194
 
142
- filename == other.filename && content_type == other.content_type && content == other.content
195
+ filename == other.filename &&
196
+ content_type == other.content_type &&
197
+ content == other.content &&
198
+ inline_id == other.inline_id
143
199
  end
144
200
 
145
201
  def eql?(other)
@@ -147,7 +203,7 @@ module Laneful
147
203
  end
148
204
 
149
205
  def hash
150
- [filename, content_type, content].hash
206
+ [filename, content_type, content, inline_id].hash
151
207
  end
152
208
 
153
209
  def to_s
@@ -193,12 +249,13 @@ module Laneful
193
249
 
194
250
  # Represents a single email to be sent
195
251
  class Email
196
- attr_reader :from, :to, :cc, :bcc, :subject, :text_content, :html_content,
252
+ attr_reader :from, :from_header, :to, :cc, :bcc, :subject, :text_content, :html_content,
197
253
  :template_id, :template_data, :attachments, :headers, :reply_to,
198
254
  :send_time, :webhook_data, :tag, :tracking
199
255
 
200
256
  def initialize(builder)
201
257
  @from = builder.instance_variable_get(:@from)
258
+ @from_header = builder.instance_variable_get(:@from_header)
202
259
  @to = builder.instance_variable_get(:@to).dup.freeze
203
260
  @cc = builder.instance_variable_get(:@cc).dup.freeze
204
261
  @bcc = builder.instance_variable_get(:@bcc).dup.freeze
@@ -222,6 +279,7 @@ module Laneful
222
279
  builder = Builder.new
223
280
 
224
281
  builder.from(Address.from_hash(data['from'])) if data && data['from']
282
+ builder.from_header(Address.from_hash(data['from_header'])) if data && data['from_header']
225
283
 
226
284
  data['to'].each { |to_data| builder.to(Address.from_hash(to_data)) } if data && data['to']
227
285
 
@@ -253,6 +311,7 @@ module Laneful
253
311
 
254
312
  # Required fields
255
313
  hash['from'] = from.to_hash
314
+ hash['from_header'] = from_header.to_hash if from_header
256
315
  hash['to'] = to.map(&:to_hash)
257
316
  hash['subject'] = subject if subject
258
317
 
@@ -278,6 +337,7 @@ module Laneful
278
337
  return false unless other.is_a?(Email)
279
338
 
280
339
  from == other.from &&
340
+ from_header == other.from_header &&
281
341
  to == other.to &&
282
342
  cc == other.cc &&
283
343
  bcc == other.bcc &&
@@ -300,7 +360,7 @@ module Laneful
300
360
  end
301
361
 
302
362
  def hash
303
- [from, to, cc, bcc, subject, text_content, html_content, template_id,
363
+ [from, from_header, to, cc, bcc, subject, text_content, html_content, template_id,
304
364
  template_data, attachments, headers, reply_to, send_time, webhook_data, tag, tracking].hash
305
365
  end
306
366
 
@@ -340,6 +400,15 @@ module Laneful
340
400
  raise ValidationException, 'Email must have either content (text/HTML) or a template ID'
341
401
  end
342
402
 
403
+ if webhook_data
404
+ raise ValidationException, 'Webhook data cannot have more than 20 keys' if webhook_data.size > 20
405
+
406
+ webhook_data.each do |key, value|
407
+ raise ValidationException, 'Webhook data keys cannot exceed 50 characters' if key.to_s.length > 50
408
+ raise ValidationException, 'Webhook data values cannot exceed 100 characters' if value.to_s.length > 100
409
+ end
410
+ end
411
+
343
412
  # Validate send time
344
413
  return unless send_time && send_time <= Time.now.to_i
345
414
 
@@ -360,6 +429,11 @@ module Laneful
360
429
  self
361
430
  end
362
431
 
432
+ def from_header(address)
433
+ @from_header = address
434
+ self
435
+ end
436
+
363
437
  def to(address)
364
438
  @to << address
365
439
  self
@@ -0,0 +1,452 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Laneful
4
+ # An unsubscribe group in a workspace
5
+ class UnsubscribeGroup
6
+ attr_reader :unsubscribe_group_id, :name, :created_at
7
+
8
+ def initialize(unsubscribe_group_id:, name:, created_at: 0)
9
+ @unsubscribe_group_id = unsubscribe_group_id
10
+ @name = name
11
+ @created_at = created_at
12
+ end
13
+
14
+ def self.from_hash(data)
15
+ new(
16
+ unsubscribe_group_id: (data['unsubscribe_group_id'] || 0).to_i,
17
+ name: data['name'] || '',
18
+ created_at: (data['created_at'] || 0).to_i
19
+ )
20
+ end
21
+
22
+ def to_hash
23
+ {
24
+ 'unsubscribe_group_id' => unsubscribe_group_id,
25
+ 'name' => name,
26
+ 'created_at' => created_at
27
+ }
28
+ end
29
+ end
30
+
31
+ # Paginated list of unsubscribe groups
32
+ class ListUnsubscribeGroupsResponse
33
+ attr_reader :unsubscribe_groups, :next_cursor
34
+
35
+ def initialize(unsubscribe_groups:, next_cursor: nil)
36
+ @unsubscribe_groups = unsubscribe_groups || []
37
+ @next_cursor = next_cursor
38
+ end
39
+
40
+ def self.from_hash(data)
41
+ groups = (data['unsubscribe_groups'] || []).map { |group| UnsubscribeGroup.from_hash(group) }
42
+ new(unsubscribe_groups: groups, next_cursor: data['next_cursor'])
43
+ end
44
+ end
45
+
46
+ # Query parameters for listing unsubscribe groups
47
+ class ListUnsubscribeGroupsParams
48
+ attr_reader :cursor, :limit, :search
49
+
50
+ def initialize(cursor: nil, limit: nil, search: nil)
51
+ @cursor = cursor
52
+ @limit = limit
53
+ @search = search
54
+ end
55
+
56
+ def to_query
57
+ query = {}
58
+ query['cursor'] = cursor if present?(cursor)
59
+ query['limit'] = limit if limit&.positive?
60
+ query['search'] = search if present?(search)
61
+ query
62
+ end
63
+
64
+ private
65
+
66
+ def present?(value)
67
+ !value.nil? && !value.to_s.empty?
68
+ end
69
+ end
70
+
71
+ # A sending domain and its verification state
72
+ class Domain
73
+ attr_reader :domain, :tracking, :return_path, :verified, :tracking_verified,
74
+ :return_path_verified, :dkim1_verified, :dkim2_verified,
75
+ :dmarc_verified, :require_tls, :email_track_id
76
+
77
+ def initialize(domain:, tracking: '', return_path: '', verified: false, tracking_verified: false,
78
+ return_path_verified: false, dkim1_verified: false, dkim2_verified: false,
79
+ dmarc_verified: false, require_tls: false, email_track_id: '')
80
+ @domain = domain
81
+ @tracking = tracking
82
+ @return_path = return_path
83
+ @verified = verified
84
+ @tracking_verified = tracking_verified
85
+ @return_path_verified = return_path_verified
86
+ @dkim1_verified = dkim1_verified
87
+ @dkim2_verified = dkim2_verified
88
+ @dmarc_verified = dmarc_verified
89
+ @require_tls = require_tls
90
+ @email_track_id = email_track_id
91
+ end
92
+
93
+ def self.from_hash(data)
94
+ new(
95
+ domain: data['domain'] || '',
96
+ tracking: data['tracking'] || '',
97
+ return_path: data['return_path'] || '',
98
+ verified: truthy?(data['verified']),
99
+ tracking_verified: truthy?(data['tracking_verified']),
100
+ return_path_verified: truthy?(data['return_path_verified']),
101
+ dkim1_verified: truthy?(data['dkim1_verified']),
102
+ dkim2_verified: truthy?(data['dkim2_verified']),
103
+ dmarc_verified: truthy?(data['dmarc_verified']),
104
+ require_tls: truthy?(data['require_tls']),
105
+ email_track_id: data['email_track_id'] || ''
106
+ )
107
+ end
108
+
109
+ def to_hash
110
+ {
111
+ 'domain' => domain,
112
+ 'tracking' => tracking,
113
+ 'return_path' => return_path,
114
+ 'verified' => verified,
115
+ 'tracking_verified' => tracking_verified,
116
+ 'return_path_verified' => return_path_verified,
117
+ 'dkim1_verified' => dkim1_verified,
118
+ 'dkim2_verified' => dkim2_verified,
119
+ 'dmarc_verified' => dmarc_verified,
120
+ 'require_tls' => require_tls,
121
+ 'email_track_id' => email_track_id
122
+ }
123
+ end
124
+
125
+ def self.truthy?(value)
126
+ value == true
127
+ end
128
+ end
129
+
130
+ # Paginated list of sending domains
131
+ class ListDomainsResponse
132
+ attr_reader :domains, :next_cursor
133
+
134
+ def initialize(domains:, next_cursor: nil)
135
+ @domains = domains || []
136
+ @next_cursor = next_cursor
137
+ end
138
+
139
+ def self.from_hash(data)
140
+ pagination = data['pagination'] || {}
141
+ new(
142
+ domains: (data['domains'] || []).map { |domain| Domain.from_hash(domain) },
143
+ next_cursor: pagination['next_cursor']
144
+ )
145
+ end
146
+ end
147
+
148
+ # Query parameters for listing domains
149
+ class ListDomainsParams
150
+ attr_reader :cursor, :limit, :filter_domain
151
+
152
+ def initialize(cursor: nil, limit: nil, filter_domain: nil)
153
+ @cursor = cursor
154
+ @limit = limit
155
+ @filter_domain = filter_domain
156
+ end
157
+
158
+ def to_query
159
+ query = {}
160
+ query['cursor'] = cursor if present?(cursor)
161
+ query['limit'] = limit if limit&.positive?
162
+ query['filter[domain]'] = filter_domain if present?(filter_domain)
163
+ query
164
+ end
165
+
166
+ private
167
+
168
+ def present?(value)
169
+ !value.nil? && !value.to_s.empty?
170
+ end
171
+ end
172
+
173
+ # Request body for creating a sending domain
174
+ class CreateDomainRequest
175
+ attr_reader :domain, :tracking, :return_path, :require_tls, :email_track_id
176
+
177
+ def initialize(domain:, tracking:, return_path:, require_tls: nil, email_track_id: nil)
178
+ @domain = domain
179
+ @tracking = tracking
180
+ @return_path = return_path
181
+ @require_tls = require_tls
182
+ @email_track_id = email_track_id
183
+ end
184
+
185
+ def to_hash
186
+ hash = {
187
+ 'domain' => domain,
188
+ 'tracking' => tracking,
189
+ 'return_path' => return_path
190
+ }
191
+ hash['require_tls'] = require_tls unless require_tls.nil?
192
+ hash['email_track_id'] = email_track_id if email_track_id && !email_track_id.empty?
193
+ hash
194
+ end
195
+ end
196
+
197
+ # Request body for updating a domain's mutable settings.
198
+ # Pass a track ID to set the email track, an empty string to clear it,
199
+ # or nil to leave it unchanged.
200
+ class UpdateDomainRequest
201
+ attr_reader :email_track_id
202
+
203
+ def initialize(email_track_id = nil)
204
+ @email_track_id = email_track_id
205
+ end
206
+
207
+ def to_hash
208
+ return {} if email_track_id.nil?
209
+
210
+ { 'email_track_id' => email_track_id }
211
+ end
212
+ end
213
+
214
+ # Generic success message from mutating endpoints
215
+ class SuccessResponse
216
+ attr_reader :message
217
+
218
+ def initialize(message:)
219
+ @message = message
220
+ end
221
+
222
+ def self.from_hash(data)
223
+ new(message: data['message'] || '')
224
+ end
225
+
226
+ def to_hash
227
+ { 'message' => message }
228
+ end
229
+ end
230
+
231
+ # A sending domain whose spam complaint ratio reached a critical level
232
+ class DomainSpamRatioRadar
233
+ attr_reader :workspace_id, :domain, :esp, :spam_ratio, :date
234
+
235
+ def initialize(workspace_id:, domain:, esp:, spam_ratio:, date:)
236
+ @workspace_id = workspace_id
237
+ @domain = domain
238
+ @esp = esp
239
+ @spam_ratio = spam_ratio
240
+ @date = date
241
+ end
242
+
243
+ def self.from_hash(data)
244
+ new(
245
+ workspace_id: (data['workspace_id'] || 0).to_i,
246
+ domain: data['domain'] || '',
247
+ esp: data['esp'] || '',
248
+ spam_ratio: (data['spam_ratio'] || 0).to_f,
249
+ date: (data['date'] || '').to_s
250
+ )
251
+ end
252
+ end
253
+
254
+ # Paginated list of domain spam-ratio radar entries
255
+ class ListDomainSpamRatioRadarResponse
256
+ attr_reader :radar, :next_cursor
257
+
258
+ def initialize(radar:, next_cursor: nil)
259
+ @radar = radar || []
260
+ @next_cursor = next_cursor
261
+ end
262
+
263
+ def self.from_hash(data)
264
+ new(
265
+ radar: (data['radar'] || []).map { |entry| DomainSpamRatioRadar.from_hash(entry) },
266
+ next_cursor: data['next_cursor']
267
+ )
268
+ end
269
+ end
270
+
271
+ # Query parameters for listing domain spam-ratio radar entries
272
+ class ListDomainSpamRatioRadarParams
273
+ attr_reader :workspace_ids, :domain, :start_date, :end_date, :cursor, :limit
274
+
275
+ def initialize(workspace_ids: [], domain: nil, start_date: nil, end_date: nil, cursor: nil, limit: nil)
276
+ @workspace_ids = workspace_ids || []
277
+ @domain = domain
278
+ @start_date = start_date
279
+ @end_date = end_date
280
+ @cursor = cursor
281
+ @limit = limit
282
+ end
283
+
284
+ def to_query
285
+ query = {}
286
+ query['workspace_ids'] = workspace_ids unless workspace_ids.empty?
287
+ query['domain'] = domain if present?(domain)
288
+ query['start_date'] = start_date if present?(start_date)
289
+ query['end_date'] = end_date if present?(end_date)
290
+ query['cursor'] = cursor if present?(cursor)
291
+ query['limit'] = limit if limit&.positive?
292
+ query
293
+ end
294
+
295
+ private
296
+
297
+ def present?(value)
298
+ !value.nil? && !value.to_s.empty?
299
+ end
300
+ end
301
+
302
+ # A daily Gmail spam-rate report from Google Postmaster Tools
303
+ class GooglePostmasterSpamReport
304
+ attr_reader :workspace_id, :domain, :date, :spam_ratio
305
+
306
+ def initialize(workspace_id:, domain:, date:, spam_ratio:)
307
+ @workspace_id = workspace_id
308
+ @domain = domain
309
+ @date = date
310
+ @spam_ratio = spam_ratio
311
+ end
312
+
313
+ def self.from_hash(data)
314
+ new(
315
+ workspace_id: (data['workspace_id'] || 0).to_i,
316
+ domain: data['domain'] || '',
317
+ date: (data['date'] || '').to_s,
318
+ spam_ratio: (data['spam_ratio'] || 0).to_f
319
+ )
320
+ end
321
+ end
322
+
323
+ # Paginated list of Google Postmaster spam reports
324
+ class ListGooglePostmasterSpamReportsResponse
325
+ attr_reader :spam_reports, :next_cursor
326
+
327
+ def initialize(spam_reports:, next_cursor: nil)
328
+ @spam_reports = spam_reports || []
329
+ @next_cursor = next_cursor
330
+ end
331
+
332
+ def self.from_hash(data)
333
+ new(
334
+ spam_reports: (data['spam_reports'] || []).map { |report| GooglePostmasterSpamReport.from_hash(report) },
335
+ next_cursor: data['next_cursor']
336
+ )
337
+ end
338
+ end
339
+
340
+ # Query parameters for listing Google Postmaster spam reports
341
+ class ListGooglePostmasterSpamReportsParams
342
+ attr_reader :workspace_ids, :domain, :start_date, :end_date, :cursor, :limit
343
+
344
+ def initialize(workspace_ids: [], domain: nil, start_date: nil, end_date: nil, cursor: nil, limit: nil)
345
+ @workspace_ids = workspace_ids || []
346
+ @domain = domain
347
+ @start_date = start_date
348
+ @end_date = end_date
349
+ @cursor = cursor
350
+ @limit = limit
351
+ end
352
+
353
+ def to_query
354
+ query = {}
355
+ query['workspace_ids'] = workspace_ids unless workspace_ids.empty?
356
+ query['domain'] = domain if present?(domain)
357
+ query['start_date'] = start_date if present?(start_date)
358
+ query['end_date'] = end_date if present?(end_date)
359
+ query['cursor'] = cursor if present?(cursor)
360
+ query['limit'] = limit if limit&.positive?
361
+ query
362
+ end
363
+
364
+ private
365
+
366
+ def present?(value)
367
+ !value.nil? && !value.to_s.empty?
368
+ end
369
+ end
370
+
371
+ # A daily Microsoft SNDS report for a sending IP
372
+ class SndsReport
373
+ FILTER_UNKNOWN = ''
374
+ FILTER_GREEN = 'GREEN'
375
+ FILTER_YELLOW = 'YELLOW'
376
+ FILTER_RED = 'RED'
377
+
378
+ attr_reader :ip, :date, :rcpt_commands, :data_commands, :message_recipients,
379
+ :filter_result, :complaint_rate, :trap_hits
380
+
381
+ def initialize(ip:, date:, rcpt_commands:, data_commands:, message_recipients:,
382
+ filter_result:, complaint_rate:, trap_hits:)
383
+ @ip = ip
384
+ @date = date
385
+ @rcpt_commands = rcpt_commands
386
+ @data_commands = data_commands
387
+ @message_recipients = message_recipients
388
+ @filter_result = filter_result
389
+ @complaint_rate = complaint_rate
390
+ @trap_hits = trap_hits
391
+ end
392
+
393
+ def self.from_hash(data)
394
+ new(
395
+ ip: data['ip'] || '',
396
+ date: (data['date'] || '').to_s,
397
+ rcpt_commands: (data['rcpt_commands'] || 0).to_i,
398
+ data_commands: (data['data_commands'] || 0).to_i,
399
+ message_recipients: (data['message_recipients'] || 0).to_i,
400
+ filter_result: data['filter_result'] || FILTER_UNKNOWN,
401
+ complaint_rate: (data['complaint_rate'] || 0).to_f,
402
+ trap_hits: (data['trap_hits'] || 0).to_i
403
+ )
404
+ end
405
+ end
406
+
407
+ # Paginated list of Microsoft SNDS reports
408
+ class ListSndsReportsResponse
409
+ attr_reader :snds_reports, :next_cursor
410
+
411
+ def initialize(snds_reports:, next_cursor: nil)
412
+ @snds_reports = snds_reports || []
413
+ @next_cursor = next_cursor
414
+ end
415
+
416
+ def self.from_hash(data)
417
+ new(
418
+ snds_reports: (data['snds_reports'] || []).map { |report| SndsReport.from_hash(report) },
419
+ next_cursor: data['next_cursor']
420
+ )
421
+ end
422
+ end
423
+
424
+ # Query parameters for listing Microsoft SNDS reports
425
+ class ListSndsReportsParams
426
+ attr_reader :ip, :start_date, :end_date, :cursor, :limit
427
+
428
+ def initialize(ip: nil, start_date: nil, end_date: nil, cursor: nil, limit: nil)
429
+ @ip = ip
430
+ @start_date = start_date
431
+ @end_date = end_date
432
+ @cursor = cursor
433
+ @limit = limit
434
+ end
435
+
436
+ def to_query
437
+ query = {}
438
+ query['ip'] = ip if present?(ip)
439
+ query['start_date'] = start_date if present?(start_date)
440
+ query['end_date'] = end_date if present?(end_date)
441
+ query['cursor'] = cursor if present?(cursor)
442
+ query['limit'] = limit if limit&.positive?
443
+ query
444
+ end
445
+
446
+ private
447
+
448
+ def present?(value)
449
+ !value.nil? && !value.to_s.empty?
450
+ end
451
+ end
452
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Laneful
4
- VERSION = '1.1.1'
4
+ VERSION = '1.2.0'
5
5
  end
@@ -12,7 +12,7 @@ module Laneful
12
12
 
13
13
  # Valid event types as documented
14
14
  VALID_EVENT_TYPES = %w[
15
- delivery open click drop spam_complaint unsubscribe bounce
15
+ request delivery open click drop spam_complaint unsubscribe bounce
16
16
  ].freeze
17
17
 
18
18
  # UUID pattern for lane_id validation
data/lib/laneful.rb CHANGED
@@ -8,6 +8,7 @@ require 'base64'
8
8
  require_relative 'laneful/version'
9
9
  require_relative 'laneful/exceptions'
10
10
  require_relative 'laneful/models'
11
+ require_relative 'laneful/org_models'
11
12
  require_relative 'laneful/client'
12
13
  require_relative 'laneful/webhooks'
13
14
 
@@ -23,5 +24,5 @@ module Laneful
23
24
  DEFAULT_TIMEOUT = 30
24
25
 
25
26
  # User agent string
26
- USER_AGENT = 'laneful-ruby/1.0.1'
27
+ USER_AGENT = "laneful-ruby/#{VERSION}".freeze
27
28
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: laneful-ruby
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.1.1
4
+ version: 1.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Laneful Team
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2025-09-10 00:00:00.000000000 Z
11
+ date: 2026-09-12 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: httparty
@@ -50,6 +50,7 @@ files:
50
50
  - lib/laneful/client.rb
51
51
  - lib/laneful/exceptions.rb
52
52
  - lib/laneful/models.rb
53
+ - lib/laneful/org_models.rb
53
54
  - lib/laneful/version.rb
54
55
  - lib/laneful/webhooks.rb
55
56
  homepage: https://github.com/laneful/laneful-ruby