laneful-ruby 1.1.1 → 1.3.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: ae0fefd1de5f15988a54f6d158b70bb9037a7879d344ce217066347bec84dbd0
4
+ data.tar.gz: 5c4f9702e0fa83de52b7bbe040700817bbcabeabed1d753951cdaed0a6ea831f
5
5
  SHA512:
6
- metadata.gz: 11e4cea266db6d53a73958d9bf4b6a4106c31fe1cb7bcba62f52959444fc9c70d2b2432906bb4c540365b0edbe76af09d4f55a80f84a1f16445595d0f8242cce
7
- data.tar.gz: 0f94b21d5267eb709cbada7cca3225f2eb4a6cebdfa694a2d63caec3d2fc7a7bf3f375e564cbedec8632fd08c91b0546a883175a9d591661c759bf562de82270
6
+ metadata.gz: bb8596d2eccaac4b25963ef3bfa5dc0eff89bd3884d5a44fa61b10892aad841d342d06f303918e68e3a49cf17509519fa3bcf3a577c2672cb2cf4c82dbb8182c
7
+ data.tar.gz: 7012812bed4f9c9c217d1aa6299c76eeb43c29cc8c4effe3cf0b78aaae6340992483b05ed58e1e975d95e98f11029f76f813e0c0775345150062a3dab15dcc8c
@@ -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,71 @@ module Laneful
103
115
  end
104
116
  end
105
117
 
118
+ # Request-level mail settings (sandbox mode, return message IDs, idempotency key).
119
+ #
120
+ # idempotency_key protects against duplicate sends: Laneful accepts a key once per
121
+ # organization for one hour. A repeated request with the same key is answered with
122
+ # status "accepted" (and no message IDs) and sends nothing. Keys are 1 to 36 bytes.
123
+ class MailSettings
124
+ attr_reader :sandbox_mode, :return_message_ids, :idempotency_key
125
+
126
+ def initialize(sandbox_mode: nil, return_message_ids: nil, idempotency_key: nil)
127
+ @sandbox_mode = sandbox_mode
128
+ @return_message_ids = return_message_ids
129
+ @idempotency_key = idempotency_key
130
+ validate_idempotency_key!
131
+ end
132
+
133
+ def self.from_hash(data)
134
+ new(
135
+ sandbox_mode: data['sandbox_mode'],
136
+ return_message_ids: data['return_message_ids'],
137
+ idempotency_key: data['idempotency_key']
138
+ )
139
+ end
140
+
141
+ def to_hash
142
+ hash = {}
143
+ hash['sandbox_mode'] = sandbox_mode unless sandbox_mode.nil?
144
+ hash['return_message_ids'] = return_message_ids unless return_message_ids.nil?
145
+ hash['idempotency_key'] = idempotency_key unless idempotency_key.nil?
146
+ hash
147
+ end
148
+
149
+ def ==(other)
150
+ return false unless other.is_a?(MailSettings)
151
+
152
+ sandbox_mode == other.sandbox_mode && return_message_ids == other.return_message_ids &&
153
+ idempotency_key == other.idempotency_key
154
+ end
155
+
156
+ def eql?(other)
157
+ self == other
158
+ end
159
+
160
+ def hash
161
+ [sandbox_mode, return_message_ids, idempotency_key].hash
162
+ end
163
+
164
+ private
165
+
166
+ def validate_idempotency_key!
167
+ return if idempotency_key.nil?
168
+ return if idempotency_key.bytesize.between?(1, 36)
169
+
170
+ raise ValidationException, 'idempotency_key must contain between 1 and 36 bytes'
171
+ end
172
+ end
173
+
106
174
  # Represents a file attachment for an email
107
175
  class Attachment
108
- attr_reader :filename, :content_type, :content
176
+ attr_reader :filename, :content_type, :content, :inline_id
109
177
 
110
- def initialize(filename, content_type, content)
178
+ def initialize(filename, content_type, content, inline_id = nil)
111
179
  @filename = filename
112
180
  @content_type = content_type
113
181
  @content = content
182
+ @inline_id = inline_id
114
183
  validate!
115
184
  end
116
185
 
@@ -124,22 +193,27 @@ module Laneful
124
193
 
125
194
  # Creates an attachment from a hash representation
126
195
  def self.from_hash(data)
127
- filename = data['file_name'] || data['filename'] # Support both field names
128
- new(filename, data['content_type'], data['content'])
196
+ filename = data['file_name'] || data['filename']
197
+ new(filename, data['content_type'], data['content'], data['inline_id'])
129
198
  end
130
199
 
131
200
  def to_hash
132
- {
201
+ hash = {
133
202
  'file_name' => filename,
134
203
  'content_type' => content_type,
135
204
  'content' => content
136
205
  }
206
+ hash['inline_id'] = inline_id if inline_id && !inline_id.empty?
207
+ hash
137
208
  end
138
209
 
139
210
  def ==(other)
140
211
  return false unless other.is_a?(Attachment)
141
212
 
142
- filename == other.filename && content_type == other.content_type && content == other.content
213
+ filename == other.filename &&
214
+ content_type == other.content_type &&
215
+ content == other.content &&
216
+ inline_id == other.inline_id
143
217
  end
144
218
 
145
219
  def eql?(other)
@@ -147,7 +221,7 @@ module Laneful
147
221
  end
148
222
 
149
223
  def hash
150
- [filename, content_type, content].hash
224
+ [filename, content_type, content, inline_id].hash
151
225
  end
152
226
 
153
227
  def to_s
@@ -193,12 +267,13 @@ module Laneful
193
267
 
194
268
  # Represents a single email to be sent
195
269
  class Email
196
- attr_reader :from, :to, :cc, :bcc, :subject, :text_content, :html_content,
270
+ attr_reader :from, :from_header, :to, :cc, :bcc, :subject, :text_content, :html_content,
197
271
  :template_id, :template_data, :attachments, :headers, :reply_to,
198
272
  :send_time, :webhook_data, :tag, :tracking
199
273
 
200
274
  def initialize(builder)
201
275
  @from = builder.instance_variable_get(:@from)
276
+ @from_header = builder.instance_variable_get(:@from_header)
202
277
  @to = builder.instance_variable_get(:@to).dup.freeze
203
278
  @cc = builder.instance_variable_get(:@cc).dup.freeze
204
279
  @bcc = builder.instance_variable_get(:@bcc).dup.freeze
@@ -222,6 +297,7 @@ module Laneful
222
297
  builder = Builder.new
223
298
 
224
299
  builder.from(Address.from_hash(data['from'])) if data && data['from']
300
+ builder.from_header(Address.from_hash(data['from_header'])) if data && data['from_header']
225
301
 
226
302
  data['to'].each { |to_data| builder.to(Address.from_hash(to_data)) } if data && data['to']
227
303
 
@@ -253,6 +329,7 @@ module Laneful
253
329
 
254
330
  # Required fields
255
331
  hash['from'] = from.to_hash
332
+ hash['from_header'] = from_header.to_hash if from_header
256
333
  hash['to'] = to.map(&:to_hash)
257
334
  hash['subject'] = subject if subject
258
335
 
@@ -278,6 +355,7 @@ module Laneful
278
355
  return false unless other.is_a?(Email)
279
356
 
280
357
  from == other.from &&
358
+ from_header == other.from_header &&
281
359
  to == other.to &&
282
360
  cc == other.cc &&
283
361
  bcc == other.bcc &&
@@ -300,7 +378,7 @@ module Laneful
300
378
  end
301
379
 
302
380
  def hash
303
- [from, to, cc, bcc, subject, text_content, html_content, template_id,
381
+ [from, from_header, to, cc, bcc, subject, text_content, html_content, template_id,
304
382
  template_data, attachments, headers, reply_to, send_time, webhook_data, tag, tracking].hash
305
383
  end
306
384
 
@@ -340,6 +418,15 @@ module Laneful
340
418
  raise ValidationException, 'Email must have either content (text/HTML) or a template ID'
341
419
  end
342
420
 
421
+ if webhook_data
422
+ raise ValidationException, 'Webhook data cannot have more than 20 keys' if webhook_data.size > 20
423
+
424
+ webhook_data.each do |key, value|
425
+ raise ValidationException, 'Webhook data keys cannot exceed 50 characters' if key.to_s.length > 50
426
+ raise ValidationException, 'Webhook data values cannot exceed 100 characters' if value.to_s.length > 100
427
+ end
428
+ end
429
+
343
430
  # Validate send time
344
431
  return unless send_time && send_time <= Time.now.to_i
345
432
 
@@ -360,6 +447,11 @@ module Laneful
360
447
  self
361
448
  end
362
449
 
450
+ def from_header(address)
451
+ @from_header = address
452
+ self
453
+ end
454
+
363
455
  def to(address)
364
456
  @to << address
365
457
  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.3.0'
5
5
  end
@@ -10,14 +10,12 @@ module Laneful
10
10
  SIGNATURE_PREFIX = 'sha256='
11
11
  SIGNATURE_HEADER_NAME = 'x-webhook-signature'
12
12
 
13
- # Valid event types as documented
13
+ # Documented event types. Informational: parse_webhook_payload keeps events of other
14
+ # types, so new event types don't make the whole payload fail.
14
15
  VALID_EVENT_TYPES = %w[
15
- delivery open click drop spam_complaint unsubscribe bounce
16
+ request delivery open click drop spam_complaint unsubscribe bounce
16
17
  ].freeze
17
18
 
18
- # UUID pattern for lane_id validation
19
- UUID_PATTERN = /\A[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\z/i
20
-
21
19
  # Verifies a webhook signature with support for sha256= prefix
22
20
  # @param secret [String] The webhook secret
23
21
  # @param payload [String] The webhook payload
@@ -133,9 +131,7 @@ module Laneful
133
131
  raise ArgumentError, "Missing required field: #{field}" unless event.key?(field)
134
132
  end
135
133
 
136
- # Validate event type
137
- event_type = event['event']
138
- raise ArgumentError, "Invalid event type: #{event_type}" unless VALID_EVENT_TYPES.include?(event_type)
134
+ raise ArgumentError, 'Missing required field: event' if event['event'].to_s.empty?
139
135
 
140
136
  # Validate email format
141
137
  email = event['email']
@@ -149,9 +145,7 @@ module Laneful
149
145
  raise ArgumentError, 'Invalid timestamp format'
150
146
  end
151
147
 
152
- # Validate lane_id is a valid UUID format
153
- lane_id = event['lane_id']
154
- raise ArgumentError, "Invalid lane_id format: #{lane_id}" unless UUID_PATTERN.match?(lane_id)
148
+ # lane_id isn't checked: request events have an empty lane_id.
155
149
 
156
150
  # Return event with all fields (required + optional)
157
151
  event
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.3.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-10-14 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: httparty
@@ -50,16 +50,17 @@ 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
- homepage: https://github.com/laneful/laneful-ruby
56
+ homepage: https://github.com/lanefulhq/laneful-ruby
56
57
  licenses:
57
58
  - MIT
58
59
  metadata:
59
- homepage_uri: https://github.com/laneful/laneful-ruby
60
- source_code_uri: https://github.com/laneful/laneful-ruby
61
- changelog_uri: https://github.com/laneful/laneful-ruby/blob/main/CHANGELOG.md
62
- documentation_uri: https://docs.laneful.com/ruby
60
+ homepage_uri: https://github.com/lanefulhq/laneful-ruby
61
+ source_code_uri: https://github.com/lanefulhq/laneful-ruby
62
+ changelog_uri: https://github.com/lanefulhq/laneful-ruby/blob/main/CHANGELOG.md
63
+ documentation_uri: https://laneful.com/docs/ruby-sdk/
63
64
  rubygems_mfa_required: 'true'
64
65
  post_install_message:
65
66
  rdoc_options: []