laneful-ruby 1.1.0 → 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: 3b7c5f86e1e341b49312a27486067c2427323569459dba65f8fb2b247629a1fb
4
- data.tar.gz: 649ca87028b16f0ede2b1e063390fe13043e12401e70e4637d6c69922d7b415e
3
+ metadata.gz: ed5ff94086f558d6ea4bad6c914ec22cbc7b8d012cc27fc3d385d3cbc05c5c49
4
+ data.tar.gz: 02a6ba4d5c4dbee58809d4e15214623fc273770b3779d73fe6ae25b61de75252
5
5
  SHA512:
6
- metadata.gz: 1d0b108965868436f5b4cbdc6208891bfa51b877a3ec7b70cc4bf13745b793aeb8676d284e010e446acd27de221637c38e005d8399dde5a60adfaf3bf2ae9749
7
- data.tar.gz: a50c27e76b6f4b47a8cc6ecee7e5b863bc6f3106127ba9e22e9e822e3b803c809c42202e78412b4b2afe5ab5afcafcbcb2f10c16d78dc923f57e453ca8b14c6b
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,21 +175,27 @@ module Laneful
124
175
 
125
176
  # Creates an attachment from a hash representation
126
177
  def self.from_hash(data)
127
- new(data['filename'], data['content_type'], data['content'])
178
+ filename = data['file_name'] || data['filename']
179
+ new(filename, data['content_type'], data['content'], data['inline_id'])
128
180
  end
129
181
 
130
182
  def to_hash
131
- {
132
- 'filename' => filename,
183
+ hash = {
184
+ 'file_name' => filename,
133
185
  'content_type' => content_type,
134
186
  'content' => content
135
187
  }
188
+ hash['inline_id'] = inline_id if inline_id && !inline_id.empty?
189
+ hash
136
190
  end
137
191
 
138
192
  def ==(other)
139
193
  return false unless other.is_a?(Attachment)
140
194
 
141
- 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
142
199
  end
143
200
 
144
201
  def eql?(other)
@@ -146,7 +203,7 @@ module Laneful
146
203
  end
147
204
 
148
205
  def hash
149
- [filename, content_type, content].hash
206
+ [filename, content_type, content, inline_id].hash
150
207
  end
151
208
 
152
209
  def to_s
@@ -192,12 +249,13 @@ module Laneful
192
249
 
193
250
  # Represents a single email to be sent
194
251
  class Email
195
- 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,
196
253
  :template_id, :template_data, :attachments, :headers, :reply_to,
197
254
  :send_time, :webhook_data, :tag, :tracking
198
255
 
199
256
  def initialize(builder)
200
257
  @from = builder.instance_variable_get(:@from)
258
+ @from_header = builder.instance_variable_get(:@from_header)
201
259
  @to = builder.instance_variable_get(:@to).dup.freeze
202
260
  @cc = builder.instance_variable_get(:@cc).dup.freeze
203
261
  @bcc = builder.instance_variable_get(:@bcc).dup.freeze
@@ -221,6 +279,7 @@ module Laneful
221
279
  builder = Builder.new
222
280
 
223
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']
224
283
 
225
284
  data['to'].each { |to_data| builder.to(Address.from_hash(to_data)) } if data && data['to']
226
285
 
@@ -252,6 +311,7 @@ module Laneful
252
311
 
253
312
  # Required fields
254
313
  hash['from'] = from.to_hash
314
+ hash['from_header'] = from_header.to_hash if from_header
255
315
  hash['to'] = to.map(&:to_hash)
256
316
  hash['subject'] = subject if subject
257
317
 
@@ -277,6 +337,7 @@ module Laneful
277
337
  return false unless other.is_a?(Email)
278
338
 
279
339
  from == other.from &&
340
+ from_header == other.from_header &&
280
341
  to == other.to &&
281
342
  cc == other.cc &&
282
343
  bcc == other.bcc &&
@@ -299,7 +360,7 @@ module Laneful
299
360
  end
300
361
 
301
362
  def hash
302
- [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,
303
364
  template_data, attachments, headers, reply_to, send_time, webhook_data, tag, tracking].hash
304
365
  end
305
366
 
@@ -339,6 +400,15 @@ module Laneful
339
400
  raise ValidationException, 'Email must have either content (text/HTML) or a template ID'
340
401
  end
341
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
+
342
412
  # Validate send time
343
413
  return unless send_time && send_time <= Time.now.to_i
344
414
 
@@ -359,6 +429,11 @@ module Laneful
359
429
  self
360
430
  end
361
431
 
432
+ def from_header(address)
433
+ @from_header = address
434
+ self
435
+ end
436
+
362
437
  def to(address)
363
438
  @to << address
364
439
  self