sevk 1.0.0 → 1.1.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.
@@ -0,0 +1,247 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sevk
4
+ module Markup
5
+ module TemplateEngine
6
+ MAX_IF_PASSES = 2000
7
+ MAX_EACH_ITEMS = 1000
8
+ MAX_EACH_OUTPUT = 2_000_000
9
+ MAX_TEMPLATE_WORK = 20_000_000
10
+ OUTPUT_EXCEEDED = 'Email block exceeds the maximum template output size'
11
+
12
+ private
13
+
14
+ def truthy?(val)
15
+ return false if val.nil?
16
+ return false if val == false
17
+ return false if val.is_a?(Numeric) && val.zero?
18
+ return false if val.is_a?(String) && val.empty?
19
+ return false if val.is_a?(Array) && val.empty?
20
+
21
+ true
22
+ end
23
+
24
+ def evaluate_condition(expr, config)
25
+ trimmed = expr.strip
26
+
27
+ return trimmed.split('||').any? { |part| evaluate_condition(part, config) } if trimmed.include?('||')
28
+
29
+ return trimmed.split('&&').all? { |part| evaluate_condition(part, config) } if trimmed.include?('&&')
30
+
31
+ if (eq_match = trimmed.match(/^(\w+)\s*==\s*"([^"]*)"$/))
32
+ return template_text(config[eq_match[1]]) == eq_match[2]
33
+ end
34
+
35
+ if (neq_match = trimmed.match(/^(\w+)\s*!=\s*"([^"]*)"$/))
36
+ return template_text(config[neq_match[1]]) != neq_match[2]
37
+ end
38
+
39
+ truthy?(config[trimmed])
40
+ end
41
+
42
+ def render_template(template, config)
43
+ @template_rendered = 0
44
+ result = render_conditionals(template, config)
45
+ result = render_loops(result, config)
46
+
47
+ result = result.gsub(/\{%(\w+)\s*\?\?([^%]+)%\}/) do
48
+ value = config[Regexp.last_match(1)]
49
+ charge_template_output(value.nil? || value == '' ? Regexp.last_match(2).strip : template_text(value))
50
+ end
51
+
52
+ result.gsub(/\{%(\w+)%\}/) { charge_template_output(template_text(config[Regexp.last_match(1)])) }
53
+ end
54
+
55
+ def charge_template_output(value)
56
+ raise ArgumentError, OUTPUT_EXCEEDED if @template_rendered + value.length > MAX_EACH_OUTPUT
57
+
58
+ @template_rendered += value.length
59
+ value
60
+ end
61
+
62
+ def render_conditionals(template, config)
63
+ result = template
64
+ pattern = %r{\{%#if\s+([^%]+)%\}((?:(?!\{%#if\s)[\s\S])*?)\{%/if%\}}
65
+ work = 0
66
+
67
+ MAX_IF_PASSES.times do
68
+ work += result.length
69
+ raise ArgumentError, RenderLimits::TOO_COMPLEX if work > MAX_TEMPLATE_WORK
70
+
71
+ replaced = result.sub(pattern) do
72
+ condition = Regexp.last_match(1)
73
+ body = Regexp.last_match(2)
74
+ else_index = body.index('{%else%}')
75
+ if else_index.nil?
76
+ evaluate_condition(condition, config) ? body : ''
77
+ else
78
+ evaluate_condition(condition, config) ? body[0...else_index] : body[(else_index + 8)..]
79
+ end
80
+ end
81
+ break if replaced == result
82
+
83
+ result = replaced
84
+ end
85
+
86
+ result
87
+ end
88
+
89
+ def render_loops(template, config)
90
+ return template unless template.include?('{%/each%}')
91
+
92
+ output = +''
93
+ from = 0
94
+ work = 0
95
+
96
+ while from <= template.length
97
+ open = template.match(/\{%#each\s+(\w+)(?:\s+as\s+(\w+))?%\}/, from)
98
+ if open.nil?
99
+ output << template[from..]
100
+ break
101
+ end
102
+
103
+ body_start = open.end(0)
104
+ close_index = template.index('{%/each%}', body_start)
105
+ if close_index.nil?
106
+ output << template[from..]
107
+ break
108
+ end
109
+
110
+ output << template[from...open.begin(0)]
111
+ items = config[open[1]]
112
+
113
+ if items.is_a?(Array) && !items.empty?
114
+ if items.length > MAX_EACH_ITEMS
115
+ raise ArgumentError, "Email block exceeds the maximum of #{MAX_EACH_ITEMS} loop items"
116
+ end
117
+
118
+ alias_name = open[2] || 'this'
119
+ body = template[body_start...close_index]
120
+ property_pattern = /\{%#{Regexp.escape(alias_name)}\.(\w+)%\}/
121
+
122
+ items.each do |item|
123
+ work += body.length + 16
124
+ raise ArgumentError, RenderLimits::TOO_COMPLEX if work > MAX_TEMPLATE_WORK
125
+
126
+ output << render_loop_item(body, item, config, property_pattern)
127
+ end
128
+ end
129
+
130
+ from = close_index + 9
131
+ end
132
+
133
+ output
134
+ end
135
+
136
+ def item_property(item, name)
137
+ return item[name] if item.is_a?(Hash)
138
+ return nil unless name == 'length'
139
+
140
+ case item
141
+ when String then item.encode('UTF-16LE').bytesize / 2
142
+ when Array then item.length
143
+ end
144
+ end
145
+
146
+ def render_loop_item(body, item, config, property_pattern)
147
+ item_out = 0
148
+ charge = lambda do |value|
149
+ raise ArgumentError, OUTPUT_EXCEEDED if @template_rendered + item_out + value.length > MAX_EACH_OUTPUT
150
+
151
+ item_out += value.length
152
+ value
153
+ end
154
+
155
+ rendered = body.gsub(property_pattern) { charge.call(template_text(item_property(item, Regexp.last_match(1)))) }
156
+ rendered = rendered.gsub(/\{%(\w+)%\}/) { charge.call(template_text(config[Regexp.last_match(1)])) }
157
+ @template_rendered += rendered.length
158
+ raise ArgumentError, OUTPUT_EXCEEDED if @template_rendered > MAX_EACH_OUTPUT
159
+
160
+ rendered
161
+ end
162
+
163
+ def template_text(value)
164
+ case value
165
+ when nil then ''
166
+ when true then 'true'
167
+ when false then 'false'
168
+ when Integer, Float then js_number(value.to_f)
169
+ when Hash then '[object Object]'
170
+ when Array then value.map { |item| item.is_a?(Hash) ? '[object Object]' : template_text(item) }.join(',')
171
+ else value.to_s
172
+ end
173
+ end
174
+
175
+ def process_block_tag(attrs, inner = '')
176
+ template = inner.strip.empty? ? (attrs['template'] || '') : inner.strip
177
+ return '' if template.empty?
178
+
179
+ config_str = (attrs['config'] || '{}').gsub(/&(?:amp|quot|apos|lt|gt|#39|#x27|#34|#x22);/i) do |entity|
180
+ {
181
+ '&amp;' => '&', '&quot;' => '"', '&#34;' => '"', '&#x22;' => '"',
182
+ '&apos;' => "'", '&#39;' => "'", '&#x27;' => "'", '&lt;' => '<', '&gt;' => '>'
183
+ }.fetch(entity.downcase)
184
+ end
185
+ config = begin
186
+ JSON.parse(config_str)
187
+ rescue JSON::ParserError
188
+ begin
189
+ JSON.parse(config_str.gsub("'", '"'))
190
+ rescue JSON::ParserError
191
+ {}
192
+ end
193
+ end
194
+ render_template(template, config.is_a?(Hash) ? config : {})
195
+ end
196
+
197
+ def parse_padding(style)
198
+ pt = pr = pb = pl = 0
199
+ if style['padding']
200
+ parts = style['padding'].split
201
+ case parts.length
202
+ when 1
203
+ val = parse_px(parts[0])
204
+ pt = pr = pb = pl = val
205
+ when 2
206
+ vertical = parse_px(parts[0])
207
+ horizontal = parse_px(parts[1])
208
+ pt = vertical
209
+ pr = horizontal
210
+ pb = vertical
211
+ pl = horizontal
212
+ when 3
213
+ pt = parse_px(parts[0])
214
+ pr = parse_px(parts[1])
215
+ pb = parse_px(parts[2])
216
+ pl = parse_px(parts[1])
217
+ when 4
218
+ pt = parse_px(parts[0])
219
+ pr = parse_px(parts[1])
220
+ pb = parse_px(parts[2])
221
+ pl = parse_px(parts[3])
222
+ end
223
+ end
224
+ pt = parse_px(style['padding-top']) if style['padding-top']
225
+ pr = parse_px(style['padding-right']) if style['padding-right']
226
+ pb = parse_px(style['padding-bottom']) if style['padding-bottom']
227
+ pl = parse_px(style['padding-left']) if style['padding-left']
228
+ [pt, pr, pb, pl]
229
+ end
230
+
231
+ def parse_px(value)
232
+ value.to_s[/\A\s*[+-]?\d+/].to_i
233
+ end
234
+
235
+ def px_to_pt(pixels)
236
+ ((pixels * 3) + 2) / 4
237
+ end
238
+
239
+ def compute_font_width_and_space_count(expected_width)
240
+ return [0, 0] unless expected_width.positive?
241
+
242
+ space_count = (expected_width / 10.0).ceil.clamp(1, 1000)
243
+ [expected_width.to_f / space_count / 2.0, space_count]
244
+ end
245
+ end
246
+ end
247
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base'
4
+
5
+ module Sevk
6
+ module Resources
7
+ class Activity < Base
8
+ # Lists project activity with optional pagination and filtering.
9
+ def list(page: nil, limit: nil, after: nil, before: nil, category: nil, type: nil, days: nil, search: nil)
10
+ type = type.join(',') if type.is_a?(Array)
11
+ params = { page: page, limit: limit, after: after, before: before, category: category, type: type, days: days,
12
+ search: search }
13
+
14
+ client.get("/activity#{build_query_string(params)}")
15
+ end
16
+
17
+ # Returns aggregate counts and rates for recent project activity.
18
+ def stats
19
+ client.get('/activity/stats')
20
+ end
21
+ end
22
+ end
23
+ end
@@ -1,46 +1,57 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "base"
3
+ require_relative 'base'
4
4
 
5
5
  module Sevk
6
6
  module Resources
7
7
  class Audiences < Base
8
- def list(page: nil, limit: nil)
9
- params = { page: page, limit: limit }
8
+ # Lists audiences with optional pagination and search.
9
+ def list(page: nil, limit: nil, after: nil, before: nil, search: nil)
10
+ params = { page: page, limit: limit, after: after, before: before, search: search }
11
+
10
12
  client.get("/audiences#{build_query_string(params)}")
11
13
  end
12
14
 
15
+ # Retrieves an audience by id.
13
16
  def get(id)
14
17
  client.get("/audiences/#{id}")
15
18
  end
16
19
 
17
- def create(name:, description: nil, users_can_see: nil)
20
+ # Creates an audience for organizing contacts, topics, and segments.
21
+ def create(name:, description: nil, visibility: nil)
18
22
  body = { name: name }
19
- body[:description] = description if description
20
- body[:usersCanSee] = users_can_see if users_can_see
21
- client.post("/audiences", body)
23
+ body[:description] = description unless description.nil?
24
+ body[:visibility] = visibility unless visibility.nil?
25
+
26
+ client.post('/audiences', body)
22
27
  end
23
28
 
24
- def update(id, name: nil, description: nil, users_can_see: nil)
29
+ # Updates an audience while preserving omitted fields.
30
+ def update(id, name: nil, description: nil, visibility: nil)
25
31
  body = {}
26
- body[:name] = name if name
27
- body[:description] = description if description
28
- body[:usersCanSee] = users_can_see if users_can_see
32
+ body[:name] = name unless name.nil?
33
+ body[:description] = description unless description.nil?
34
+ body[:visibility] = visibility unless visibility.nil?
35
+
29
36
  client.put("/audiences/#{id}", body)
30
37
  end
31
38
 
39
+ # Deletes an audience without deleting its contacts.
32
40
  def delete(id)
33
41
  client.delete("/audiences/#{id}")
34
42
  end
35
43
 
44
+ # Adds contacts to an audience, ignoring memberships that already exist.
36
45
  def add_contacts(audience_id, contact_ids)
37
46
  client.post("/audiences/#{audience_id}/contacts", { contactIds: contact_ids })
38
47
  end
39
48
 
49
+ # Lists contacts attached to an audience with optional filtering.
40
50
  def list_contacts(audience_id, params = {})
41
51
  client.get("/audiences/#{audience_id}/contacts#{build_query_string(params)}")
42
52
  end
43
53
 
54
+ # Removes a contact from the audience without deleting the contact.
44
55
  def remove_contact(audience_id, contact_id)
45
56
  client.delete("/audiences/#{audience_id}/contacts/#{contact_id}")
46
57
  end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'uri'
4
+
3
5
  module Sevk
4
6
  module Resources
5
7
  class Base
@@ -12,10 +14,20 @@ module Sevk
12
14
  private
13
15
 
14
16
  def build_query_string(params)
15
- return "" if params.nil? || params.empty?
17
+ return '' if params.nil? || params.empty?
18
+
19
+ compacted = wire_keys(params).compact
20
+ return '' if compacted.empty?
21
+
22
+ "?#{URI.encode_www_form(compacted)}"
23
+ end
24
+
25
+ def wire_keys(params)
26
+ params.transform_keys { |key| wire_key(key) }
27
+ end
16
28
 
17
- query = params.compact.map { |k, v| "#{k}=#{v}" }.join("&")
18
- query.empty? ? "" : "?#{query}"
29
+ def wire_key(key)
30
+ key.to_s.gsub(/_([a-z0-9])/) { Regexp.last_match(1).upcase }.to_sym
19
31
  end
20
32
  end
21
33
  end
@@ -1,61 +1,105 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "base"
3
+ require_relative 'base'
4
4
 
5
5
  module Sevk
6
6
  module Resources
7
7
  class Broadcasts < Base
8
- def list(page: nil, limit: nil, search: nil, status: nil)
9
- params = { page: page, limit: limit, search: search, status: status }
8
+ # Lists broadcasts with optional pagination, search, and status filtering.
9
+ def list(page: nil, limit: nil, after: nil, before: nil, search: nil, status: nil)
10
+ params = { page: page, limit: limit, after: after, before: before, search: search, status: status }
11
+
10
12
  client.get("/broadcasts#{build_query_string(params)}")
11
13
  end
12
14
 
15
+ # Retrieves a broadcast by id and includes its current delivery state.
13
16
  def get(id)
14
17
  client.get("/broadcasts/#{id}")
15
18
  end
16
19
 
20
+ # Creates a draft or scheduled broadcast for a target group.
17
21
  def create(params)
18
- client.post("/broadcasts", params)
22
+ client.post('/broadcasts', broadcast_body(params))
19
23
  end
20
24
 
25
+ # Updates a broadcast while preserving omitted fields. The response includes
26
+ # variants and abTest when this mutation touched experiment state.
21
27
  def update(id, params)
22
- client.put("/broadcasts/#{id}", params)
28
+ client.put("/broadcasts/#{id}", broadcast_body(params))
23
29
  end
24
30
 
31
+ # Deletes a broadcast that has not been sent.
25
32
  def delete(id)
26
33
  client.delete("/broadcasts/#{id}")
27
34
  end
28
35
 
36
+ # Queues a prepared broadcast for delivery.
29
37
  def send(id)
30
38
  client.post("/broadcasts/#{id}/send")
31
39
  end
32
40
 
41
+ # Cancels a broadcast when its state still allows cancellation.
33
42
  def cancel(id)
34
43
  client.post("/broadcasts/#{id}/cancel")
35
44
  end
36
45
 
37
- def send_test(id, params)
38
- client.post("/broadcasts/#{id}/test", params)
46
+ # Sends the broadcast to test recipients without starting the campaign.
47
+ def test(id, params, options: nil)
48
+ client.post("/broadcasts/#{id}/test", wire_keys(params), headers: idempotency_headers(options))
39
49
  end
40
50
 
41
- def get_analytics(id)
51
+ # Returns aggregate delivery and engagement analytics for a broadcast.
52
+ def analytics(id)
42
53
  client.get("/broadcasts/#{id}/analytics")
43
54
  end
44
55
 
45
- def get_status(id)
56
+ # Returns live scheduling and delivery progress for a broadcast.
57
+ def status(id)
46
58
  client.get("/broadcasts/#{id}/status")
47
59
  end
48
60
 
49
- def get_emails(id, params = {})
61
+ # Lists email deliveries created for a broadcast.
62
+ def emails(id, params = {})
50
63
  client.get("/broadcasts/#{id}/emails#{build_query_string(params)}")
51
64
  end
52
65
 
66
+ # Estimates delivery cost without sending the broadcast.
53
67
  def estimate_cost(id)
54
68
  client.get("/broadcasts/#{id}/estimate-cost")
55
69
  end
56
70
 
57
- def list_active
58
- client.get("/broadcasts/active")
71
+ # Resolves the subscribed recipient count for a broadcast target.
72
+ def estimate_recipients(params)
73
+ client.post('/broadcasts/estimate-recipients', wire_keys(params))
74
+ end
75
+
76
+ # Lists broadcasts that are currently scheduled or processing.
77
+ def active
78
+ client.get('/broadcasts/active')
79
+ end
80
+
81
+ private
82
+
83
+ def broadcast_body(params)
84
+ body = wire_keys(params)
85
+
86
+ if body[:variants].is_a?(Array)
87
+ body[:variants] = body[:variants].map { |variant| variant.is_a?(Hash) ? wire_keys(variant) : variant }
88
+ end
89
+
90
+ body[:abTest] = wire_keys(body[:abTest]) if body[:abTest].is_a?(Hash)
91
+
92
+ body
93
+ end
94
+
95
+ def idempotency_headers(options)
96
+ return nil unless options
97
+
98
+ return nil unless options.key?(:idempotency_key) || options.key?('idempotency_key')
99
+
100
+ key = options.key?(:idempotency_key) ? options[:idempotency_key] : options['idempotency_key']
101
+
102
+ { 'Idempotency-Key' => key.to_s }
59
103
  end
60
104
  end
61
105
  end
@@ -1,51 +1,66 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "base"
3
+ require_relative 'base'
4
4
 
5
5
  module Sevk
6
6
  module Resources
7
7
  class Contacts < Base
8
- def list(page: nil, limit: nil, search: nil)
9
- params = { page: page, limit: limit, search: search }
8
+ UNSET = Object.new
9
+ private_constant :UNSET
10
+
11
+ # Lists contacts with optional pagination, search, and subscription filters.
12
+ def list(page: nil, limit: nil, after: nil, before: nil, search: nil, exclude_audience: nil, subscribed: nil)
13
+ params = { page: page, limit: limit, after: after, before: before, search: search,
14
+ excludeAudience: exclude_audience }
15
+ params[:subscribed] = subscribed unless subscribed.nil?
16
+
10
17
  client.get("/contacts#{build_query_string(params)}")
11
18
  end
12
19
 
20
+ # Retrieves a contact by id.
13
21
  def get(id)
14
22
  client.get("/contacts/#{id}")
15
23
  end
16
24
 
17
- def create(email:, first_name: nil, last_name: nil, subscribed: nil, metadata: nil)
25
+ # Creates a contact and optionally attaches custom metadata.
26
+ def create(email:, subscribed: nil, data: nil)
18
27
  body = { email: email }
19
- body[:firstName] = first_name if first_name
20
- body[:lastName] = last_name if last_name
21
28
  body[:subscribed] = subscribed unless subscribed.nil?
22
- body[:metadata] = metadata if metadata
23
- client.post("/contacts", body)
29
+ body[:data] = data unless data.nil?
30
+
31
+ client.post('/contacts', body)
24
32
  end
25
33
 
26
- def update(id, first_name: nil, last_name: nil, subscribed: nil, metadata: nil)
34
+ # Updates a contact. Explicit nil clears metadata; omitted data preserves it.
35
+ def update(id, email: nil, subscribed: nil, data: UNSET)
27
36
  body = {}
28
- body[:firstName] = first_name if first_name
29
- body[:lastName] = last_name if last_name
37
+ body[:email] = email unless email.nil?
30
38
  body[:subscribed] = subscribed unless subscribed.nil?
31
- body[:metadata] = metadata if metadata
39
+ body[:data] = data unless data.equal?(UNSET)
40
+
32
41
  client.put("/contacts/#{id}", body)
33
42
  end
34
43
 
44
+ # Deletes a contact and removes its audience and topic memberships.
35
45
  def delete(id)
36
46
  client.delete("/contacts/#{id}")
37
47
  end
38
48
 
49
+ # Updates multiple contacts by email and reports entries that were not found.
39
50
  def bulk_update(updates)
40
- client.put("/contacts/bulk-update", updates)
51
+ client.put('/contacts/bulk-update', updates)
41
52
  end
42
53
 
43
- def import_csv(params)
44
- client.post("/contacts/import", params)
54
+ # Imports contacts in one request, optionally attaching them to a group.
55
+ def bulk_import(params)
56
+ client.post('/contacts/import', wire_keys(params))
45
57
  end
46
58
 
47
- def get_events(id)
48
- client.get("/contacts/#{id}/events")
59
+ # Lists delivery and engagement activity for a contact.
60
+ def activity(id, page: nil, limit: nil, after: nil, before: nil)
61
+ params = { page: page, limit: limit, after: after, before: before }
62
+
63
+ client.get("/contacts/#{id}/activity#{build_query_string(params)}")
49
64
  end
50
65
  end
51
66
  end
@@ -1,41 +1,55 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "base"
3
+ require_relative 'base'
4
4
 
5
5
  module Sevk
6
6
  module Resources
7
7
  class Domains < Base
8
- def list(verified: nil)
9
- params = { verified: verified }
8
+ # Lists sending domains, optionally filtered by verification state.
9
+ def list(verified: nil, page: nil, limit: nil, after: nil, before: nil)
10
+ params = { verified: verified, page: page, limit: limit, after: after, before: before }
11
+
10
12
  client.get("/domains#{build_query_string(params)}")
11
13
  end
12
14
 
15
+ # Retrieves a domain and its current verification state.
13
16
  def get(id)
14
17
  client.get("/domains/#{id}")
15
18
  end
16
19
 
20
+ # registers an exact domain claim; dns fields depend on domain:dns-read.
17
21
  def create(params)
18
- client.post("/domains", params)
22
+ client.post('/domains', wire_keys(params))
19
23
  end
20
24
 
25
+ # Updates sending, tracking, TLS, or inbound settings for a domain.
21
26
  def update(id, params)
22
- client.put("/domains/#{id}", params)
27
+ client.put("/domains/#{id}", wire_keys(params))
23
28
  end
24
29
 
30
+ # Deletes a domain after the API confirms it is safe to remove.
25
31
  def delete(id)
26
32
  client.delete("/domains/#{id}")
27
33
  end
28
34
 
35
+ # Rechecks sending DNS records and returns the updated domain state.
29
36
  def verify(id)
30
37
  client.post("/domains/#{id}/verify", {})
31
38
  end
32
39
 
33
- def get_dns_records(id)
40
+ # Checks the inbound MX record and returns details useful for DNS diagnosis.
41
+ def verify_inbound(id)
42
+ client.post("/domains/#{id}/inbound/verify", {})
43
+ end
44
+
45
+ # Returns the DNS records required for sending and inbound mail.
46
+ def dns_records(id)
34
47
  client.get("/domains/#{id}/dns-records")
35
48
  end
36
49
 
37
- def get_regions
38
- client.get("/domains/regions")
50
+ # Lists region identifiers accepted when registering a domain.
51
+ def regions
52
+ client.get('/domains/regions')
39
53
  end
40
54
  end
41
55
  end