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 +4 -4
- data/LICENSE +21 -0
- data/README.md +6 -10
- data/lib/sevk/client.rb +148 -58
- data/lib/sevk/error.rb +38 -5
- data/lib/sevk/markup/document_parser.rb +83 -0
- data/lib/sevk/markup/html_document.rb +82 -0
- data/lib/sevk/markup/number_formatting.rb +40 -0
- data/lib/sevk/markup/render_limits.rb +52 -0
- data/lib/sevk/markup/renderer.rb +282 -351
- data/lib/sevk/markup/style_helpers.rb +131 -0
- data/lib/sevk/markup/syntax_highlighting.rb +227 -0
- data/lib/sevk/markup/tag_scanner.rb +112 -0
- data/lib/sevk/markup/template_engine.rb +247 -0
- data/lib/sevk/resources/activity.rb +23 -0
- data/lib/sevk/resources/audiences.rb +30 -11
- data/lib/sevk/resources/base.rb +15 -3
- data/lib/sevk/resources/broadcasts.rb +91 -3
- data/lib/sevk/resources/contacts.rb +39 -12
- data/lib/sevk/resources/domains.rb +41 -3
- data/lib/sevk/resources/emails.rb +91 -9
- data/lib/sevk/resources/inbound.rb +123 -0
- data/lib/sevk/resources/outbound.rb +55 -0
- data/lib/sevk/resources/segments.rb +37 -15
- data/lib/sevk/resources/subscriptions.rb +18 -11
- data/lib/sevk/resources/templates.rb +20 -8
- data/lib/sevk/resources/topics.rb +37 -14
- data/lib/sevk/resources/usage.rb +15 -0
- data/lib/sevk/resources/webhooks.rb +52 -0
- data/lib/sevk/version.rb +1 -1
- data/lib/sevk/webhook_verifier.rb +91 -0
- data/lib/sevk.rb +24 -192
- metadata +25 -82
- data/Rakefile +0 -42
- data/sevk.gemspec +0 -40
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 75681328c3b453118de3b5624ab68540a11b0d2af7ce42470b83f1cdc9735cf9
|
|
4
|
+
data.tar.gz: 2e9af383dc99a61c39337dcef817b34949fa05fc162b2304aa3c20e88a5bf74f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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="
|
|
2
|
+
<img src="https://sevk.io/logo.png" alt="sevk" width="120" />
|
|
7
3
|
</p>
|
|
8
4
|
|
|
9
|
-
<h1 align="center">
|
|
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">
|
|
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
|
|
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.
|
|
74
|
-
conn.
|
|
75
|
-
conn.headers[
|
|
76
|
-
conn.headers[
|
|
77
|
-
conn.headers[
|
|
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
|
-
|
|
84
|
-
|
|
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
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
-
|
|
113
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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
|
|
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
|
|
157
|
-
|
|
158
|
-
|
|
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
|
-
"#{
|
|
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(
|
|
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
|
|
59
|
+
class BadRequestError < Error; end
|
|
60
|
+
class UnauthorizedError < Error; end
|
|
61
|
+
class ForbiddenError < Error; end
|
|
36
62
|
class NotFoundError < Error; end
|
|
37
|
-
class
|
|
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
|
|
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
|