sevk 0.1.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6c7f36600a0d24e9b0125300b35f125d85c3c2d116902386c4336c1969c539b3
4
- data.tar.gz: 99ca2a8b06fba11e1d604ea021cd8664b5cd5cd48a01eaef4a0fd38219acf9d2
3
+ metadata.gz: 75681328c3b453118de3b5624ab68540a11b0d2af7ce42470b83f1cdc9735cf9
4
+ data.tar.gz: 2e9af383dc99a61c39337dcef817b34949fa05fc162b2304aa3c20e88a5bf74f
5
5
  SHA512:
6
- metadata.gz: ede0ed66474c75ed2544e0e5c1d41013946af97d81728ecdccedc7b1022a2d9ccd5bc8a47f67c99bc5b8a1038f4bb1fb84983024a12d8f08e070565f61bcb70b
7
- data.tar.gz: eab455f069c6d246b19385ca7842c66f14d16781fd837d0c5d05c2bcf894973b81eee0ff1d96886c1a4a2e4c6b9b6cad18062ca8a5c87b26c5a2937f2923fbfb
6
+ metadata.gz: 549a3cc87a0f01185a81d5e773a55dd7e777a36268f578853a136e9e80f7af8080f2fe70bfe34368d706f50759a28158218549cf3f88b6cf09e67cb3e5254fb1
7
+ data.tar.gz: bc2f63abfc1cf26c0202b4aa2d99b4a3aae126768ac332d6e00e80b702016aab7b03ccb73f420c65c9da9cf85e3a2924b39393a2c567db83fa4eabd84036c7d3
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 sevk
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md CHANGED
@@ -1,15 +1,11 @@
1
- > [!WARNING]
2
- > Sevk is currently in private beta. This SDK is not yet available for public use.
3
- > Join the waitlist at [sevk.io](https://sevk.io) to get early access.
4
-
5
1
  <p align="center">
6
- <img src="https://sevk.io/logo.png" alt="Sevk" width="120" />
2
+ <img src="https://sevk.io/logo.png" alt="sevk" width="120" />
7
3
  </p>
8
4
 
9
- <h1 align="center">Sevk Ruby SDK</h1>
5
+ <h1 align="center">sevk Ruby SDK</h1>
10
6
 
11
7
  <p align="center">
12
- Official Ruby SDK for <a href="https://sevk.io">Sevk</a> email platform.
8
+ Official Ruby SDK for <a href="https://sevk.io">sevk</a> email platform.
13
9
  </p>
14
10
 
15
11
  <p align="center">
@@ -28,12 +24,12 @@ gem install sevk
28
24
  ```ruby
29
25
  require 'sevk'
30
26
 
31
- client = Sevk::Client.new('your-api-key')
27
+ client = Sevk::Client.new(api_key: 'your-api-key')
32
28
 
33
29
  client.emails.send(
34
30
  to: 'recipient@example.com',
35
31
  from: 'hello@yourdomain.com',
36
- subject: 'Hello from Sevk!',
32
+ subject: 'Hello from sevk!',
37
33
  html: '<h1>Welcome!</h1>'
38
34
  )
39
35
  ```
@@ -43,7 +39,7 @@ client.emails.send(
43
39
  ```ruby
44
40
  require 'sevk'
45
41
 
46
- client = Sevk::Client.new('your-api-key')
42
+ client = Sevk::Client.new(api_key: 'your-api-key')
47
43
 
48
44
  html = Sevk::Markup.render(<<~MARKUP
49
45
  <section padding="40px 20px" background-color="#f8f9fa">
data/lib/sevk/client.rb CHANGED
@@ -2,11 +2,12 @@
2
2
 
3
3
  module Sevk
4
4
  class Client
5
- attr_reader :api_key, :base_url
5
+ attr_reader :api_key, :base_url, :timeout
6
6
 
7
- def initialize(api_key:, base_url: nil)
7
+ def initialize(api_key:, base_url: nil, timeout: 30)
8
8
  @api_key = api_key
9
9
  @base_url = base_url || DEFAULT_BASE_URL
10
+ @timeout = timeout
10
11
  end
11
12
 
12
13
  def contacts
@@ -45,12 +46,32 @@ module Sevk
45
46
  @emails ||= Resources::Emails.new(self)
46
47
  end
47
48
 
49
+ def webhooks
50
+ @webhooks ||= Resources::Webhooks.new(self)
51
+ end
52
+
53
+ def activity
54
+ @activity ||= Resources::Activity.new(self)
55
+ end
56
+
57
+ def inbound
58
+ @inbound ||= Resources::Inbound.new(self)
59
+ end
60
+
61
+ def outbound
62
+ @outbound ||= Resources::Outbound.new(self)
63
+ end
64
+
65
+ def usage
66
+ @usage ||= Resources::Usage.new(self)
67
+ end
68
+
48
69
  def get(path, params = {})
49
70
  request(:get, path, params)
50
71
  end
51
72
 
52
- def post(path, body = {})
53
- request(:post, path, body)
73
+ def post(path, body = {}, headers: nil)
74
+ request(:post, path, body, headers: headers)
54
75
  end
55
76
 
56
77
  def patch(path, body = {})
@@ -70,95 +91,164 @@ module Sevk
70
91
  def connection
71
92
  @connection ||= Faraday.new do |conn|
72
93
  conn.request :json
73
- conn.response :json, content_type: /\bjson$/
74
- conn.request :retry, max: 2, interval: 0.5, backoff_factor: 2
75
- conn.headers["Authorization"] = "Bearer #{api_key}"
76
- conn.headers["Content-Type"] = "application/json"
77
- conn.headers["Accept"] = "application/json"
94
+ conn.options.timeout = timeout
95
+ conn.options.open_timeout = timeout
96
+ conn.headers['Authorization'] = "Bearer #{api_key}"
97
+ conn.headers['Content-Type'] = 'application/json'
98
+ conn.headers['Accept'] = 'application/json'
99
+ conn.headers['User-Agent'] = "sevk-ruby/#{VERSION}"
78
100
  conn.adapter Faraday.default_adapter
79
101
  end
80
102
  end
81
103
 
82
104
  def build_url(path)
83
- # Ensure base_url ends without slash and path starts with slash
84
- base = base_url.chomp("/")
85
- path = "/#{path}" unless path.start_with?("/")
105
+ base = base_url.sub(%r{/+\z}, '')
106
+ path = "/#{path}" unless path.start_with?('/')
86
107
  "#{base}#{path}"
87
108
  end
88
109
 
89
- def request(method, path, body = nil)
110
+ def request(method, path, body = nil, headers: nil)
90
111
  url = build_url(path)
112
+ body = encode_body(body) if !body.nil? && %i[post patch put].include?(method)
91
113
  response = case method
92
114
  when :get
93
115
  connection.get(url, body)
94
116
  when :post
95
- connection.post(url, body)
117
+ connection.post(url, body) { |req| apply_extra_headers(req, headers) }
96
118
  when :patch
97
- connection.patch(url, body)
119
+ connection.patch(url, body) { |req| apply_extra_headers(req, headers) }
98
120
  when :put
99
- connection.put(url, body)
121
+ connection.put(url, body) { |req| apply_extra_headers(req, headers) }
100
122
  when :delete
101
123
  connection.delete(url)
102
124
  end
103
125
 
104
126
  handle_response(response)
127
+ rescue Faraday::TimeoutError => e
128
+ raise Error.new(e.message, status_code: 0, code: 'TIMEOUT')
129
+ rescue Faraday::ConnectionFailed => e
130
+ code = e.wrapped_exception.is_a?(Net::OpenTimeout) ? 'TIMEOUT' : 'NETWORK_ERROR'
131
+ raise Error.new(e.message, status_code: 0, code: code)
132
+ rescue Faraday::Error => e
133
+ raise Error.new(e.message, status_code: 0, code: 'NETWORK_ERROR')
134
+ end
135
+
136
+ def encode_body(body)
137
+ JSON.generate(body)
138
+ rescue JSON::GeneratorError, JSON::NestingError => e
139
+ raise Error.new("Request body cannot be JSON encoded: #{e.message}", status_code: 0, code: 'SERIALIZATION_ERROR')
140
+ end
141
+
142
+ def apply_extra_headers(req, headers)
143
+ return unless headers
144
+
145
+ headers.each { |k, v| req.headers[k.to_s] = v.to_s }
105
146
  end
106
147
 
107
148
  def handle_response(response)
108
- case response.status
109
- when 200..299
110
- response.body
149
+ return {} if response.status == 204
150
+
151
+ if (200..299).cover?(response.status)
152
+ raw_body = response.body
153
+ body = decode_json_body(raw_body)
154
+ unless body.is_a?(Hash)
155
+ raise Error.new(
156
+ 'Expected a JSON response body but received an empty or non-JSON response',
157
+ status_code: response.status,
158
+ code: 'INVALID_RESPONSE',
159
+ response: raw_body
160
+ )
161
+ end
162
+
163
+ return body
164
+ end
165
+
166
+ raise_response_error(response.status, response.body, response.headers)
167
+ end
168
+
169
+ def raise_response_error(response_status, response_body, response_headers)
170
+ decoded_body = decode_json_body(response_body)
171
+ payload = error_payload(decoded_body)
172
+ fields = normalize_fields(payload['fields'])
173
+ status = response_status
174
+ code = payload['code']
175
+ code = 'UNKNOWN_ERROR' unless code.is_a?(String) && !code.empty?
176
+ attrs = {
177
+ status_code: status,
178
+ code: code,
179
+ fields: fields,
180
+ retry_after: parse_retry_after(response_headers['Retry-After']),
181
+ response: decoded_body,
182
+ details: payload['details']
183
+ }
184
+ message = error_message(status, payload, decoded_body)
185
+
186
+ case status
111
187
  when 400
112
- raise ValidationError.new(
113
- error_message(response),
114
- status_code: response.status,
115
- response: response.body
116
- )
188
+ klass = fields.empty? && payload['code'] != 'VALIDATION_ERROR' ? BadRequestError : ValidationError
189
+ raise klass.new(message, **attrs)
117
190
  when 401
118
- raise AuthenticationError.new(
119
- error_message(response),
120
- status_code: response.status,
121
- response: response.body
122
- )
191
+ raise UnauthorizedError.new(message, **attrs)
123
192
  when 403
124
- raise Error.new(
125
- error_message(response),
126
- status_code: response.status,
127
- response: response.body
128
- )
193
+ raise ForbiddenError.new(message, **attrs)
129
194
  when 404
130
- raise NotFoundError.new(
131
- error_message(response),
132
- status_code: response.status,
133
- response: response.body
134
- )
195
+ raise NotFoundError.new(message, **attrs)
196
+ when 409
197
+ raise ConflictError.new(message, **attrs)
198
+ when 422
199
+ raise ValidationError.new(message, **attrs)
135
200
  when 429
136
- raise RateLimitError.new(
137
- error_message(response),
138
- status_code: response.status,
139
- response: response.body
140
- )
201
+ raise RateLimitError.new(message, **attrs)
141
202
  when 500..599
142
- raise ServerError.new(
143
- error_message(response),
144
- status_code: response.status,
145
- response: response.body
146
- )
203
+ raise InternalServerError.new(message, **attrs)
147
204
  else
148
- raise Error.new(
149
- error_message(response),
150
- status_code: response.status,
151
- response: response.body
152
- )
205
+ raise Error.new(message, **attrs)
153
206
  end
154
207
  end
155
208
 
156
- def error_message(response)
157
- if response.body.is_a?(Hash) && response.body["message"]
158
- "#{response.status}: #{response.body['message']}"
209
+ def decode_json_body(body)
210
+ return body unless body.is_a?(String)
211
+
212
+ JSON.parse(body)
213
+ rescue JSON::ParserError
214
+ body
215
+ end
216
+
217
+ # Unwrap the nested `{ "error": { ... } }` envelope when present.
218
+ def error_payload(body)
219
+ return {} unless body.is_a?(Hash)
220
+
221
+ nested = body['error']
222
+ nested.is_a?(Hash) ? nested : body
223
+ end
224
+
225
+ def error_message(status, payload, body)
226
+ message = payload['message']
227
+ raw = body.is_a?(String) ? body.strip : ''
228
+ if message.is_a?(String) && !message.empty?
229
+ message
159
230
  else
160
- "#{response.status}: #{response.body}"
231
+ (raw.empty? ? "HTTP #{status}" : raw)
161
232
  end
162
233
  end
234
+
235
+ # Coerce each field value (a string or list of strings on the wire) into a
236
+ # list of strings.
237
+ def normalize_fields(raw)
238
+ return {} unless raw.is_a?(Hash)
239
+
240
+ raw.each_with_object({}) do |(key, value), acc|
241
+ acc[key.to_s] = value.is_a?(Array) ? value.map(&:to_s) : [value.to_s]
242
+ end
243
+ end
244
+
245
+ # Integer-seconds Retry-After only; HTTP-date forms are ignored (nil).
246
+ def parse_retry_after(value)
247
+ text = value.to_s.strip
248
+ return nil unless text.match?(/\A\d+\z/)
249
+
250
+ seconds = text.to_i
251
+ seconds <= 2_147_483_647 ? seconds : nil
252
+ end
163
253
  end
164
254
  end
data/lib/sevk/error.rb CHANGED
@@ -2,12 +2,23 @@
2
2
 
3
3
  module Sevk
4
4
  class Error < StandardError
5
- attr_reader :status_code, :code, :response
5
+ attr_reader :status_code, :code, :fields, :retry_after, :response, :details
6
6
 
7
- def initialize(message = nil, status_code: nil, code: nil, response: nil)
7
+ def initialize(
8
+ message = nil,
9
+ status_code: nil,
10
+ code: nil,
11
+ fields: nil,
12
+ retry_after: nil,
13
+ response: nil,
14
+ details: nil
15
+ )
8
16
  @status_code = status_code
9
17
  @code = code
18
+ @fields = fields || {}
19
+ @retry_after = retry_after
10
20
  @response = response
21
+ @details = details
11
22
  super(message)
12
23
  end
13
24
 
@@ -27,14 +38,36 @@ module Sevk
27
38
  status_code == 400
28
39
  end
29
40
 
41
+ def conflict?
42
+ status_code == 409
43
+ end
44
+
45
+ def validation_error?
46
+ status_code == 422 ||
47
+ (status_code == 400 && (code == 'VALIDATION_ERROR' || !fields.empty?))
48
+ end
49
+
50
+ def rate_limited?
51
+ status_code == 429
52
+ end
53
+
30
54
  def server_error?
31
55
  status_code.to_i >= 500 && status_code.to_i < 600
32
56
  end
33
57
  end
34
58
 
35
- class AuthenticationError < Error; end
59
+ class BadRequestError < Error; end
60
+ class UnauthorizedError < Error; end
61
+ class ForbiddenError < Error; end
36
62
  class NotFoundError < Error; end
37
- class ValidationError < Error; end
63
+ class ConflictError < Error; end
64
+
65
+ class ValidationError < Error
66
+ def initialize(message = nil, status_code: 0, code: 'VALIDATION_ERROR', **attributes)
67
+ super
68
+ end
69
+ end
70
+
38
71
  class RateLimitError < Error; end
39
- class ServerError < Error; end
72
+ class InternalServerError < Error; end
40
73
  end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sevk
4
+ module Markup
5
+ module DocumentParser
6
+ private
7
+
8
+ def extract_body_content(markup)
9
+ unless sevk_document?(markup)
10
+ body = get_first_tag_content(markup, 'body') || markup
11
+ preview = hidden_preview(markup)
12
+ body = body.sub(preview[0], '') if preview
13
+ return body.strip
14
+ end
15
+
16
+ body = (get_first_tag_content(markup, 'body') || '').strip
17
+ return body unless body.empty?
18
+
19
+ stripped = replace_self_closing(markup, 'email') { '' }
20
+ stripped = replace_self_closing(stripped, 'mail') { '' }.gsub(%r{</(?:email|mail)>}i, '')
21
+ %w[head title preview style].each { |tag| stripped = strip_tag_pairs(stripped, tag) }
22
+ replace_self_closing(stripped, 'font') { '' }.gsub(%r{</font>}i, '').strip
23
+ end
24
+
25
+ def sevk_document?(markup)
26
+ markup.match?(/<(?:email|mail)[\s>]/i)
27
+ end
28
+
29
+ def hidden_preview(html)
30
+ find_hidden_preview_div(html)
31
+ end
32
+
33
+ def parse_head_settings(markup)
34
+ return parse_html_head_settings(markup) unless sevk_document?(markup)
35
+
36
+ root_attrs = get_tag_attributes(markup, 'email').first || get_tag_attributes(markup, 'mail').first
37
+ if root_attrs
38
+ if (lang_match = root_attrs.match(/lang=["']([^"']*)["']/i))
39
+ @head_settings.lang = lang_match[1]
40
+ end
41
+ if (dir_match = root_attrs.match(/dir=["']([^"']*)["']/i))
42
+ @head_settings.dir = dir_match[1]
43
+ end
44
+ end
45
+
46
+ @head_settings.title = (get_first_tag_content(markup, 'title') || '').strip
47
+ @head_settings.preview_text = (get_first_tag_content(markup, 'preview') || '').strip
48
+ @head_settings.styles = (get_first_tag_content(markup, 'style') || '').strip
49
+
50
+ seen_urls = {}
51
+ get_tag_attributes(markup, 'font').each do |attributes|
52
+ name = attributes.match(/name=["']([^"']*)["']/i)
53
+ url = attributes.match(/url=["']([^"']*)["']/i)
54
+ next if name.nil? || url.nil? || seen_urls[url[1]]
55
+
56
+ seen_urls[url[1]] = true
57
+ @head_settings.fonts << FontConfig.new(
58
+ id: "font-#{@head_settings.fonts.length}", name: name[1], url: url[1]
59
+ )
60
+ end
61
+ end
62
+
63
+ def parse_html_head_settings(html)
64
+ @head_settings.title = (get_first_tag_content(html, 'title') || '').strip
65
+ @head_settings.styles = get_all_tag_contents(html, 'style').join("\n")
66
+
67
+ get_tag_attributes(html, 'link').each do |attributes|
68
+ href = attributes.match(/href=["']([^"']*)["']/i)
69
+ next if href.nil? || !href[1].downcase.include?('fonts')
70
+
71
+ family = href[1].match(/family=([^:&]+)/)
72
+ index = @head_settings.fonts.length
73
+ @head_settings.fonts << FontConfig.new(
74
+ id: "font-#{index}", name: family ? family[1].tr('+', ' ') : "Font #{index + 1}", url: href[1]
75
+ )
76
+ end
77
+
78
+ preview = hidden_preview(html)
79
+ @head_settings.preview_text = preview[1].strip if preview
80
+ end
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sevk
4
+ module Markup
5
+ module HtmlDocument
6
+ private
7
+
8
+ def generate_html(content)
9
+ lang = @head_settings.lang.empty? ? 'en' : @head_settings.lang
10
+ dir = @head_settings.dir.empty? ? 'ltr' : @head_settings.dir
11
+
12
+ title = @head_settings.title.empty? ? '' : "<title>#{@head_settings.title}</title>"
13
+
14
+ font_links = @head_settings.fonts.map do |font|
15
+ %(<link href="#{font.url}" rel="stylesheet" type="text/css" />)
16
+ end.join("\n")
17
+
18
+ styles = @head_settings.styles.empty? ? '' : %(<style type="text/css">#{@head_settings.styles}</style>)
19
+
20
+ gap_rules = @gap_rules || []
21
+ gap_style_tag =
22
+ if gap_rules.empty?
23
+ ''
24
+ else
25
+ %(<style type="text/css">@media only screen and (max-width:479px){#{gap_rules.join}}</style>)
26
+ end
27
+
28
+ preview_text = ''
29
+ unless @head_settings.preview_text.empty?
30
+ preview_style =
31
+ 'display:none;font-size:1px;color:#ffffff;line-height:1px;' \
32
+ 'max-height:0px;max-width:0px;opacity:0;overflow:hidden;'
33
+ preview_text = %(<div style="#{preview_style}">#{@head_settings.preview_text}</div>)
34
+ end
35
+
36
+ <<~HTML.chomp
37
+ <!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
38
+ <html lang="#{lang}" dir="#{dir}" xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office">
39
+ <head>
40
+ <meta content="text/html; charset=UTF-8" http-equiv="Content-Type"/>
41
+ <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
42
+ <meta name="x-apple-disable-message-reformatting"/>
43
+ <meta content="IE=edge" http-equiv="X-UA-Compatible"/>
44
+ <meta name="format-detection" content="telephone=no,address=no,email=no,date=no,url=no"/>
45
+ <!--[if mso]>
46
+ <noscript>
47
+ <xml>
48
+ <o:OfficeDocumentSettings>
49
+ <o:AllowPNG/>
50
+ <o:PixelsPerInch>96</o:PixelsPerInch>
51
+ </o:OfficeDocumentSettings>
52
+ </xml>
53
+ </noscript>
54
+ <![endif]-->
55
+ <style type="text/css">
56
+ #outlook a { padding: 0; }
57
+ body { margin: 0; padding: 0; -webkit-text-size-adjust: 100%; -ms-text-size-adjust: 100%; }
58
+ table, td { border-collapse: collapse; mso-table-lspace: 0pt; mso-table-rspace: 0pt; }
59
+ .sevk-row-table { border-collapse: separate !important; }
60
+ img { border: 0; height: auto; line-height: 100%; outline: none; text-decoration: none; -ms-interpolation-mode: bicubic; }
61
+ @media only screen and (max-width: 479px) {
62
+ .sevk-row-table { width: 100% !important; }
63
+ .sevk-column { display: block !important; width: 100% !important; max-width: 100% !important; box-sizing: border-box !important; }
64
+ }
65
+ </style>
66
+ #{gap_style_tag}
67
+ #{title}
68
+ #{font_links}
69
+ #{styles}
70
+ </head>
71
+ <body style="margin:0;padding:0;word-spacing:normal;-webkit-text-size-adjust:100%;-ms-text-size-adjust:100%;font-family:ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;">
72
+ <div aria-roledescription="email" role="article">
73
+ #{preview_text}
74
+ #{content}
75
+ </div>
76
+ </body>
77
+ </html>
78
+ HTML
79
+ end
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sevk
4
+ module Markup
5
+ module NumberFormatting
6
+ def js_number(value)
7
+ return '0' if value.zero?
8
+ return value.to_s unless value.finite?
9
+
10
+ digits, point = decimal_digits(value.abs)
11
+ text = if point.between?(digits.length, 21)
12
+ digits + ('0' * (point - digits.length))
13
+ elsif point.positive? && point <= 21
14
+ "#{digits[0...point]}.#{digits[point..]}"
15
+ elsif point > -6 && point <= 0
16
+ "0.#{'0' * -point}#{digits}"
17
+ else
18
+ exponent_notation(digits, point - 1)
19
+ end
20
+ value.negative? ? "-#{text}" : text
21
+ end
22
+
23
+ private
24
+
25
+ def decimal_digits(magnitude)
26
+ mantissa, exponent = magnitude.to_s.split('e')
27
+ integer_part, fraction_part = mantissa.split('.')
28
+ digits = integer_part + fraction_part.to_s.sub(/0+\z/, '')
29
+ leading_zeros = digits[/\A0*/].length
30
+ [digits[leading_zeros..], integer_part.length + exponent.to_i - leading_zeros]
31
+ end
32
+
33
+ def exponent_notation(digits, power)
34
+ sign = power.negative? ? '-' : '+'
35
+ mantissa = digits.length == 1 ? digits : "#{digits[0]}.#{digits[1..]}"
36
+ "#{mantissa}e#{sign}#{power.abs}"
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sevk
4
+ module Markup
5
+ module RenderLimits
6
+ MAX_MARKUP_DEPTH = 64
7
+ MAX_TAG_ITERATIONS = 200
8
+ MAX_RENDER_OUTPUT = 5_000_000
9
+ MAX_TAG_SCAN_WORK = 30_000_000
10
+ TOO_COMPLEX = 'Email markup is too complex to render'
11
+
12
+ DEPTH_TAGS = %w[
13
+ block section column row container heading paragraph text button link sevk-link list li codeblock
14
+ ].freeze
15
+
16
+ private
17
+
18
+ def charge_scan_work(work)
19
+ @scan_work += work
20
+ raise ArgumentError, TOO_COMPLEX if @scan_work > MAX_TAG_SCAN_WORK
21
+ end
22
+
23
+ def assert_markup_depth(content)
24
+ depths = Hash.new(0)
25
+ index = 0
26
+
27
+ while (open = content.index('<', index))
28
+ close = content.index('>', open + 1)
29
+ break unless close
30
+
31
+ index = close + 1
32
+ position = open + 1
33
+ closing = content[position] == '/'
34
+ position += 1 if closing
35
+ name_end = position
36
+ name_end += 1 while name_end < close && content[name_end].match?(/[A-Za-z-]/)
37
+ name = content[position...name_end].downcase
38
+ next unless DEPTH_TAGS.include?(name)
39
+
40
+ if closing
41
+ depths[name] -= 1 if depths[name].positive?
42
+ elsif content[close - 1] != '/'
43
+ depths[name] += 1
44
+ if depths[name] > MAX_MARKUP_DEPTH
45
+ raise ArgumentError, "Email markup nesting exceeds the maximum depth of #{MAX_MARKUP_DEPTH}"
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end