sevk 0.1.0 → 1.0.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: 62e0a240f8ad60264efdd2a0384dbcd25eb7f35f6bd2ca608ffa0206e0f0b376
4
+ data.tar.gz: 6179e81e538df7a45124341b773df25999483de8a48b70fdb1b37f28a650c35d
5
5
  SHA512:
6
- metadata.gz: ede0ed66474c75ed2544e0e5c1d41013946af97d81728ecdccedc7b1022a2d9ccd5bc8a47f67c99bc5b8a1038f4bb1fb84983024a12d8f08e070565f61bcb70b
7
- data.tar.gz: eab455f069c6d246b19385ca7842c66f14d16781fd837d0c5d05c2bcf894973b81eee0ff1d96886c1a4a2e4c6b9b6cad18062ca8a5c87b26c5a2937f2923fbfb
6
+ metadata.gz: 51ce594479adaaa2ee9aa02121eb8013251c8f02b23eca08b81bf4cf9441f5871faa6084d4c3e9808019fc84b4a24d279b8104163d4c07e929d54062f9d7f312
7
+ data.tar.gz: 4418c136224bf8452e88adef21165a28f81d8d72d0da38109fe0dcf36241b81578c848418a3fd7a08a4ef00d477ced5f6023b01638742d345abab17212c8ee69
data/README.md CHANGED
@@ -1,7 +1,3 @@
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
2
  <img src="https://sevk.io/logo.png" alt="Sevk" width="120" />
7
3
  </p>
data/lib/sevk/client.rb CHANGED
@@ -45,6 +45,19 @@ module Sevk
45
45
  @emails ||= Resources::Emails.new(self)
46
46
  end
47
47
 
48
+ def webhooks
49
+ @webhooks ||= Resources::Webhooks.new(self)
50
+ end
51
+
52
+ def events
53
+ @events ||= Resources::Events.new(self)
54
+ end
55
+
56
+ # Get project usage and limits
57
+ def get_usage
58
+ get("/limits")
59
+ end
60
+
48
61
  def get(path, params = {})
49
62
  request(:get, path, params)
50
63
  end
@@ -1,5 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "rouge"
4
+ require "json"
5
+
3
6
  module Sevk
4
7
  module Markup
5
8
  # Font configuration
@@ -15,13 +18,15 @@ module Sevk
15
18
 
16
19
  # Head settings for email generation
17
20
  class EmailHeadSettings
18
- attr_accessor :title, :preview_text, :styles, :fonts
21
+ attr_accessor :title, :preview_text, :styles, :fonts, :lang, :dir
19
22
 
20
23
  def initialize
21
24
  @title = ""
22
25
  @preview_text = ""
23
26
  @styles = ""
24
27
  @fonts = []
28
+ @lang = ""
29
+ @dir = ""
25
30
  end
26
31
  end
27
32
 
@@ -46,17 +51,29 @@ module Sevk
46
51
  # Parse head settings from markup
47
52
  parse_head_settings(markup)
48
53
 
54
+ # Extract clean body content (strips <mail>/<head> wrapper tags)
55
+ body_content = extract_body_content(markup)
56
+
49
57
  # Normalize markup
50
- markup = normalize_markup(markup)
58
+ normalized = normalize_markup(body_content)
51
59
 
52
60
  # Process markup using regex
53
- processed = process_markup(markup)
61
+ processed = process_markup(normalized)
54
62
 
55
63
  generate_html(processed)
56
64
  end
57
65
 
58
66
  private
59
67
 
68
+ def extract_body_content(markup)
69
+ if markup.include?("<mail") || markup.include?("<email")
70
+ if (match = markup.match(/<body[^>]*>([\s\S]*?)<\/body>/i))
71
+ return match[1].strip
72
+ end
73
+ end
74
+ markup
75
+ end
76
+
60
77
  def normalize_markup(content)
61
78
  result = content
62
79
 
@@ -74,6 +91,17 @@ module Sevk
74
91
  end
75
92
 
76
93
  def parse_head_settings(markup)
94
+ # Parse lang and dir from <mail> or <email> root tag
95
+ if (root_match = markup.match(/<(?:email|mail)([^>]*)>/i))
96
+ root_attrs = root_match[1]
97
+ if (lang_match = root_attrs.match(/lang=["']([^"']*)["']/i))
98
+ @head_settings.lang = lang_match[1]
99
+ end
100
+ if (dir_match = root_attrs.match(/dir=["']([^"']*)["']/i))
101
+ @head_settings.dir = dir_match[1]
102
+ end
103
+ end
104
+
77
105
  # Extract title
78
106
  if (match = markup.match(/<title[^>]*>([\s\S]*?)<\/title>/i))
79
107
  @head_settings.title = match[1].strip
@@ -98,6 +126,23 @@ module Sevk
98
126
  def process_markup(content)
99
127
  result = content
100
128
 
129
+ # Convert <link> to <sevk-link>
130
+ if result.include?("<link")
131
+ result = result.gsub(/<link\s+href=/i, "<sevk-link href=")
132
+ result = result.gsub("</link>", "</sevk-link>")
133
+ end
134
+
135
+ # Process block tags (before other tags)
136
+ result = process_tag(result, "block") do |attrs, inner|
137
+ process_block_tag(attrs, inner)
138
+ end
139
+
140
+ # Process self-closing block tags
141
+ result = result.gsub(/<block([^>]*)\/?\s*>/i) do
142
+ attrs = parse_attributes(Regexp.last_match(1) || "")
143
+ process_block_tag(attrs)
144
+ end
145
+
101
146
  # Process section tags
102
147
  result = process_tag(result, "section") do |attrs, inner|
103
148
  style = extract_all_style_attributes(attrs)
@@ -111,32 +156,88 @@ module Sevk
111
156
  </table>)
112
157
  end
113
158
 
159
+ # Process column tags (before rows, so row can count them)
160
+ result = process_tag(result, "column") do |attrs, inner|
161
+ style = extract_all_style_attributes(attrs)
162
+ style["vertical-align"] ||= "top"
163
+ style_str = style_to_string(style)
164
+ %(<td class="sevk-column" style="#{style_str}">#{inner}</td>)
165
+ end
166
+
114
167
  # Process row tags
168
+ row_counter = 0
115
169
  result = process_tag(result, "row") do |attrs, inner|
170
+ gap = attrs["gap"] || "0"
116
171
  style = extract_all_style_attributes(attrs)
172
+ style.delete("gap")
117
173
  style_str = style_to_string(style)
118
- %(<table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation" style="#{style_str}">
174
+ gap_px = gap.gsub("px", "")
175
+ gap_num = gap_px.to_i
176
+ row_id = "sevk-row-#{row_counter}"
177
+ row_counter += 1
178
+
179
+ # Assign equal widths to columns if more than one
180
+ processed_inner = inner
181
+ column_count = inner.scan(/class="sevk-column"/).length
182
+ if column_count > 1
183
+ equal_width = "#{(100 / column_count).to_i}%"
184
+ processed_inner = processed_inner.gsub(/<td class="sevk-column" style="([^"]*)"/) do
185
+ existing_style = Regexp.last_match(1)
186
+ if existing_style.include?("width:")
187
+ %(<td class="sevk-column" style="#{existing_style}")
188
+ else
189
+ %(<td class="sevk-column" style="width:#{equal_width};#{existing_style}")
190
+ end
191
+ end
192
+ end
193
+
194
+ gap_style = gap_num > 0 ? %(<style>@media only screen and (max-width:479px){.#{row_id} > tbody > tr > td{margin-bottom:#{gap_px}px !important;padding-left:0 !important;padding-right:0 !important;}.#{row_id} > tbody > tr > td:last-child{margin-bottom:0 !important;}}</style>) : ""
195
+ %(#{gap_style}<table class="sevk-row-table #{row_id}" align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation" style="#{style_str}">
119
196
  <tbody style="width:100%">
120
- <tr style="width:100%">#{inner}</tr>
197
+ <tr style="width:100%">#{processed_inner}</tr>
121
198
  </tbody>
122
199
  </table>)
123
200
  end
124
201
 
125
- # Process column tags
126
- result = process_tag(result, "column") do |attrs, inner|
127
- style = extract_all_style_attributes(attrs)
128
- style_str = style_to_string(style)
129
- %(<td style="#{style_str}">#{inner}</td>)
130
- end
131
-
132
202
  # Process container tags
133
203
  result = process_tag(result, "container") do |attrs, inner|
134
204
  style = extract_all_style_attributes(attrs)
135
- style_str = style_to_string(style)
136
- %(<table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation" style="#{style_str}">
205
+ td_style = {}
206
+ table_style = {}
207
+
208
+ # Visual styles go on <td>, layout styles stay on <table>
209
+ visual_keys = %w[
210
+ background-color background-image background-size background-position background-repeat
211
+ border border-top border-right border-bottom border-left border-color border-width border-style
212
+ border-radius border-top-left-radius border-top-right-radius border-bottom-left-radius border-bottom-right-radius
213
+ padding padding-top padding-right padding-bottom padding-left
214
+ ]
215
+ style.each do |key, value|
216
+ if visual_keys.include?(key)
217
+ td_style[key] = value
218
+ else
219
+ table_style[key] = value
220
+ end
221
+ end
222
+
223
+ # Add border-collapse: separate when border-radius is used
224
+ has_border_radius = td_style["border-radius"] || td_style["border-top-left-radius"] ||
225
+ td_style["border-top-right-radius"] || td_style["border-bottom-left-radius"] ||
226
+ td_style["border-bottom-right-radius"]
227
+ table_style["border-collapse"] = "separate" if has_border_radius
228
+
229
+ # Make fixed widths responsive: width becomes max-width, width set to 100%
230
+ if table_style["width"] && table_style["width"] != "100%" && table_style["width"] != "auto"
231
+ table_style["max-width"] ||= table_style["width"]
232
+ table_style["width"] = "100%"
233
+ end
234
+
235
+ table_style_str = style_to_string(table_style)
236
+ td_style_str = style_to_string(td_style)
237
+ %(<table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation" style="#{table_style_str}">
137
238
  <tbody>
138
239
  <tr style="width:100%">
139
- <td>#{inner}</td>
240
+ <td style="#{td_style_str}">#{inner}</td>
140
241
  </tr>
141
242
  </tbody>
142
243
  </table>)
@@ -146,6 +247,7 @@ module Sevk
146
247
  result = process_tag(result, "heading") do |attrs, inner|
147
248
  level = attrs["level"] || "1"
148
249
  style = extract_all_style_attributes(attrs)
250
+ style['margin'] ||= '0'
149
251
  style_str = style_to_string(style)
150
252
  %(<h#{level} style="#{style_str}">#{inner}</h#{level}>)
151
253
  end
@@ -153,6 +255,7 @@ module Sevk
153
255
  # Process paragraph tags
154
256
  result = process_tag(result, "paragraph") do |attrs, inner|
155
257
  style = extract_all_style_attributes(attrs)
258
+ style['margin'] ||= '0'
156
259
  style_str = style_to_string(style)
157
260
  %(<p style="#{style_str}">#{inner}</p>)
158
261
  end
@@ -178,13 +281,15 @@ module Sevk
178
281
  height = attrs["height"]
179
282
 
180
283
  style = extract_all_style_attributes(attrs)
284
+ style["vertical-align"] ||= "middle"
285
+ style["max-width"] ||= "100%"
181
286
  style["outline"] ||= "none"
182
287
  style["border"] ||= "none"
183
288
  style["text-decoration"] ||= "none"
184
289
 
185
290
  style_str = style_to_string(style)
186
- width_attr = width ? %( width="#{width}") : ""
187
- height_attr = height ? %( height="#{height}") : ""
291
+ width_attr = width ? %( width="#{width.to_s.gsub('px', '')}") : ""
292
+ height_attr = height ? %( height="#{height.to_s.gsub('px', '')}") : ""
188
293
 
189
294
  %(<img src="#{src}" alt="#{alt}"#{width_attr}#{height_attr} style="#{style_str}" />)
190
295
  end
@@ -199,6 +304,9 @@ module Sevk
199
304
  %(<hr style="#{style_str}"#{class_str} />)
200
305
  end
201
306
 
307
+ # Remove stray </divider> closing tags
308
+ result = result.gsub(%r{</divider>}i, "")
309
+
202
310
  # Process link tags
203
311
  result = process_tag(result, "sevk-link") do |attrs, inner|
204
312
  href = attrs["href"] || "#"
@@ -213,6 +321,7 @@ module Sevk
213
321
  list_type = attrs["type"] || "unordered"
214
322
  tag = list_type == "ordered" ? "ol" : "ul"
215
323
  style = extract_all_style_attributes(attrs)
324
+ style['margin'] ||= '0'
216
325
  style["list-style-type"] = attrs["list-style-type"] if attrs["list-style-type"]
217
326
  style_str = style_to_string(style)
218
327
  class_attr = attrs["class"] || attrs["className"]
@@ -231,12 +340,13 @@ module Sevk
231
340
 
232
341
  # Process codeblock tags
233
342
  result = process_tag(result, "codeblock") do |attrs, inner|
234
- style = extract_all_style_attributes(attrs)
235
- style["width"] ||= "100%"
236
- style["box-sizing"] ||= "border-box"
237
- style_str = style_to_string(style)
238
- escaped = inner.gsub("<", "&lt;").gsub(">", "&gt;")
239
- %(<pre style="#{style_str}"><code>#{escaped}</code></pre>)
343
+ process_codeblock(attrs, inner)
344
+ end
345
+
346
+ # Clean up stray Sevk closing tags
347
+ stray_closing_tags = %w[container section row column heading paragraph text button sevk-link]
348
+ stray_closing_tags.each do |tag|
349
+ result = result.gsub(%r{</#{tag}>}i, "")
240
350
  end
241
351
 
242
352
  # Clean up wrapper tags
@@ -292,6 +402,280 @@ module Sevk
292
402
  %(<a href="#{href}" target="_blank" style="#{style_str}"><!--[if mso]><i style="mso-font-width:#{(pl_font_width * 100).round}%;mso-text-raise:#{text_raise}" hidden>#{left_mso_spaces}</i><![endif]--><span style="max-width:100%;display:inline-block;line-height:120%;mso-padding-alt:0px;mso-text-raise:#{px_to_pt(padding_bottom)}">#{inner}</span><!--[if mso]><i style="mso-font-width:#{(pr_font_width * 100).round}%" hidden>#{right_mso_spaces}&#8203;</i><![endif]--></a>)
293
403
  end
294
404
 
405
+ # One Dark theme token-to-inline-style mapping
406
+ ONE_DARK_THEME = {
407
+ "Comment" => { "color" => "#5c6370", "font-style" => "italic" },
408
+ "Comment.Single" => { "color" => "#5c6370", "font-style" => "italic" },
409
+ "Comment.Multiline" => { "color" => "#5c6370", "font-style" => "italic" },
410
+ "Comment.Preproc" => { "color" => "#5c6370", "font-style" => "italic" },
411
+ "Comment.Doc" => { "color" => "#5c6370", "font-style" => "italic" },
412
+ "Comment.Special" => { "color" => "#5c6370", "font-style" => "italic" },
413
+ "Keyword" => { "color" => "#c678dd" },
414
+ "Keyword.Constant" => { "color" => "#c678dd" },
415
+ "Keyword.Declaration" => { "color" => "#c678dd" },
416
+ "Keyword.Namespace" => { "color" => "#c678dd" },
417
+ "Keyword.Pseudo" => { "color" => "#c678dd" },
418
+ "Keyword.Reserved" => { "color" => "#c678dd" },
419
+ "Keyword.Type" => { "color" => "#c678dd" },
420
+ "Literal.String" => { "color" => "#98c379" },
421
+ "Literal.String.Affix" => { "color" => "#98c379" },
422
+ "Literal.String.Backtick" => { "color" => "#98c379" },
423
+ "Literal.String.Char" => { "color" => "#98c379" },
424
+ "Literal.String.Doc" => { "color" => "#98c379" },
425
+ "Literal.String.Double" => { "color" => "#98c379" },
426
+ "Literal.String.Escape" => { "color" => "#d19a66" },
427
+ "Literal.String.Heredoc" => { "color" => "#98c379" },
428
+ "Literal.String.Interpol" => { "color" => "#98c379" },
429
+ "Literal.String.Other" => { "color" => "#98c379" },
430
+ "Literal.String.Regex" => { "color" => "#98c379" },
431
+ "Literal.String.Single" => { "color" => "#98c379" },
432
+ "Literal.String.Symbol" => { "color" => "#e06c75" },
433
+ "Literal.Number" => { "color" => "#d19a66" },
434
+ "Literal.Number.Bin" => { "color" => "#d19a66" },
435
+ "Literal.Number.Float" => { "color" => "#d19a66" },
436
+ "Literal.Number.Hex" => { "color" => "#d19a66" },
437
+ "Literal.Number.Integer" => { "color" => "#d19a66" },
438
+ "Literal.Number.Oct" => { "color" => "#d19a66" },
439
+ "Name.Function" => { "color" => "#61afef" },
440
+ "Name.Function.Magic" => { "color" => "#61afef" },
441
+ "Name.Builtin" => { "color" => "#61afef" },
442
+ "Name.Builtin.Pseudo" => { "color" => "#e06c75" },
443
+ "Name.Variable" => { "color" => "#e06c75" },
444
+ "Name.Variable.Class" => { "color" => "#e06c75" },
445
+ "Name.Variable.Global" => { "color" => "#e06c75" },
446
+ "Name.Variable.Instance" => { "color" => "#e06c75" },
447
+ "Name.Variable.Magic" => { "color" => "#e06c75" },
448
+ "Name.Class" => { "color" => "#d19a66" },
449
+ "Name.Constant" => { "color" => "#d19a66" },
450
+ "Name.Decorator" => { "color" => "#61afef" },
451
+ "Name.Attribute" => { "color" => "#d19a66" },
452
+ "Name.Tag" => { "color" => "#e06c75" },
453
+ "Name.Namespace" => { "color" => "#d19a66" },
454
+ "Operator" => { "color" => "#61afef" },
455
+ "Operator.Word" => { "color" => "#c678dd" },
456
+ "Punctuation" => { "color" => "#abb2bf" },
457
+ "Generic.Deleted" => { "color" => "#e06c75" },
458
+ "Generic.Inserted" => { "color" => "#98c379" },
459
+ "Generic.Heading" => { "color" => "#61afef", "font-weight" => "bold" },
460
+ "Generic.Subheading" => { "color" => "#61afef" },
461
+ "Generic.Emph" => { "font-style" => "italic" },
462
+ "Generic.Strong" => { "font-weight" => "bold" },
463
+ }.freeze
464
+
465
+ def process_codeblock(attrs, inner)
466
+ language = attrs["language"] || "text"
467
+ custom_style = extract_all_style_attributes(attrs)
468
+
469
+ code = inner.strip
470
+
471
+ # Try to find a Rouge lexer for the language
472
+ lexer = begin
473
+ Rouge::Lexer.find(language)&.new
474
+ rescue StandardError
475
+ nil
476
+ end
477
+
478
+ # Base pre styles (One Dark)
479
+ base_style = {
480
+ "background-color" => "#282c34",
481
+ "color" => "#abb2bf",
482
+ "font-family" => "'Fira Code', 'Fira Mono', Menlo, Consolas, 'DejaVu Sans Mono', monospace",
483
+ "font-size" => "13px",
484
+ "direction" => "ltr",
485
+ "text-align" => "left",
486
+ "white-space" => "pre",
487
+ "word-spacing" => "normal",
488
+ "word-break" => "normal",
489
+ "line-height" => "1.5",
490
+ "tab-size" => "2",
491
+ "hyphens" => "none",
492
+ "padding" => "1em",
493
+ "margin" => "0.5em 0",
494
+ "overflow" => "auto",
495
+ "border-radius" => "0.3em",
496
+ "width" => "100%",
497
+ "box-sizing" => "border-box"
498
+ }
499
+ base_style.merge!(custom_style)
500
+
501
+ if lexer.nil?
502
+ # Fallback: plain pre/code with no highlighting
503
+ style_str = style_to_string(base_style)
504
+ escaped = code.gsub("&", "&amp;").gsub("<", "&lt;").gsub(">", "&gt;")
505
+ return %(<pre style="#{style_str}"><code>#{escaped}</code></pre>)
506
+ end
507
+
508
+ # Tokenize and highlight
509
+ tokens = lexer.lex(code)
510
+ lines = build_highlighted_lines(tokens)
511
+
512
+ lines_html = lines.map do |line_tokens|
513
+ spans = line_tokens.map { |tok_type, tok_text| render_rouge_token(tok_type, tok_text) }.join
514
+ %(<p style="margin:0;min-height:1em">#{spans}</p>)
515
+ end
516
+
517
+ style_str = style_to_string(base_style)
518
+ %(<pre style="#{style_str}"><code>#{lines_html.join}</code></pre>)
519
+ end
520
+
521
+ def build_highlighted_lines(tokens)
522
+ lines = [[]]
523
+ tokens.each do |tok_type, tok_text|
524
+ parts = tok_text.split(/(\n)/, -1)
525
+ parts.each_with_index do |part, i|
526
+ if part == "\n"
527
+ lines << []
528
+ elsif !part.empty?
529
+ lines.last << [tok_type, part]
530
+ end
531
+ end
532
+ end
533
+ lines
534
+ end
535
+
536
+ def render_rouge_token(tok_type, text)
537
+ escaped = text.gsub("&", "&amp;").gsub("<", "&lt;").gsub(">", "&gt;")
538
+ # Replace spaces with non-breaking space + zero-width joiner + zero-width space for email safety
539
+ escaped = escaped.gsub(" ", "\u00A0\u200D\u200B")
540
+
541
+ styles = resolve_token_styles(tok_type)
542
+ if styles.empty?
543
+ escaped
544
+ else
545
+ style_str = styles.map { |k, v| "#{k}:#{v}" }.join(";")
546
+ %(<span style="#{style_str}">#{escaped}</span>)
547
+ end
548
+ end
549
+
550
+ def resolve_token_styles(tok_type)
551
+ # Walk up the token hierarchy to find a matching theme entry
552
+ # e.g., Token::Literal::String::Double -> "Literal.String.Double" -> "Literal.String" -> ...
553
+ qualname = tok_type.qualname
554
+ loop do
555
+ return ONE_DARK_THEME[qualname].dup if ONE_DARK_THEME.key?(qualname)
556
+
557
+ dot_index = qualname.rindex(".")
558
+ break unless dot_index
559
+
560
+ qualname = qualname[0...dot_index]
561
+ end
562
+
563
+ # Check base token name
564
+ return ONE_DARK_THEME[qualname].dup if ONE_DARK_THEME.key?(qualname)
565
+
566
+ {}
567
+ end
568
+
569
+ def truthy?(val)
570
+ return false if val.nil?
571
+ return false if val == false
572
+ return false if val == 0
573
+ return false if val.is_a?(String) && val.empty?
574
+ return false if val.is_a?(Array) && val.empty?
575
+
576
+ true
577
+ end
578
+
579
+ def evaluate_condition(expr, config)
580
+ trimmed = expr.strip
581
+
582
+ # OR: split on ||, return true if any part is true
583
+ if trimmed.include?('||')
584
+ return trimmed.split('||').any? { |part| evaluate_condition(part, config) }
585
+ end
586
+
587
+ # AND: split on &&, return true if all parts are true
588
+ if trimmed.include?('&&')
589
+ return trimmed.split('&&').all? { |part| evaluate_condition(part, config) }
590
+ end
591
+
592
+ # Equality: key == "value"
593
+ if (eq_match = trimmed.match(/^(\w+)\s*==\s*"([^"]*)"$/))
594
+ return (config[eq_match[1]] || '').to_s == eq_match[2]
595
+ end
596
+
597
+ # Inequality: key != "value"
598
+ if (neq_match = trimmed.match(/^(\w+)\s*!=\s*"([^"]*)"$/))
599
+ return (config[neq_match[1]] || '').to_s != neq_match[2]
600
+ end
601
+
602
+ # Simple truthy check
603
+ truthy?(config[trimmed])
604
+ end
605
+
606
+ def render_template(template, config)
607
+ result = template.dup
608
+
609
+ # Process #each loops
610
+ result = result.gsub(/\{%#each\s+(\w+)\s+as\s+(\w+)%\}([\s\S]*?)\{%\/each%\}/) do
611
+ key = Regexp.last_match(1)
612
+ alias_name = Regexp.last_match(2)
613
+ body = Regexp.last_match(3)
614
+ arr = config[key]
615
+ if arr.is_a?(Array)
616
+ arr.map do |item|
617
+ iteration = body.dup
618
+ if item.is_a?(Hash)
619
+ item.each do |prop, val|
620
+ iteration = iteration.gsub(/\{%#{Regexp.escape(alias_name)}\.#{Regexp.escape(prop)}(?:\s*\?\?\s*([^%]*?))?\s*%\}/) do
621
+ fallback = Regexp.last_match(1)
622
+ truthy?(val) ? val.to_s : (fallback ? fallback.strip : '')
623
+ end
624
+ end
625
+ end
626
+ # Replace bare alias reference
627
+ iteration = iteration.gsub(/\{%#{Regexp.escape(alias_name)}(?:\s*\?\?\s*([^%]*?))?\s*%\}/) do
628
+ fallback = Regexp.last_match(1)
629
+ truthy?(item) ? item.to_s : (fallback ? fallback.strip : '')
630
+ end
631
+ iteration
632
+ end.join
633
+ else
634
+ ''
635
+ end
636
+ end
637
+
638
+ # Process nested #if conditionals (innermost first)
639
+ loop do
640
+ matched = false
641
+ result = result.gsub(/\{%#if\s+([^%]+)%\}((?:(?!\{%#if\s)[\s\S])*?)\{%\/if%\}/) do
642
+ matched = true
643
+ condition = Regexp.last_match(1)
644
+ body = Regexp.last_match(2)
645
+ if body.include?('{%else%}')
646
+ parts = body.split('{%else%}', 2)
647
+ evaluate_condition(condition, config) ? parts[0] : parts[1]
648
+ else
649
+ evaluate_condition(condition, config) ? body : ''
650
+ end
651
+ end
652
+ break unless matched
653
+ end
654
+
655
+ # Process variable injection with fallback
656
+ result = result.gsub(/\{%(\w+)(?:\s*\?\?\s*([^%]*?))?\s*%\}/) do
657
+ key = Regexp.last_match(1)
658
+ fallback = Regexp.last_match(2)
659
+ val = config[key]
660
+ truthy?(val) ? val.to_s : (fallback ? fallback.strip : '')
661
+ end
662
+
663
+ result
664
+ end
665
+
666
+ def process_block_tag(attrs, inner = '')
667
+ template = inner.strip.empty? ? (attrs['template'] || '') : inner.strip
668
+ return '' if template.empty?
669
+
670
+ config_str = (attrs['config'] || '{}').gsub("'", '"').gsub('&quot;', '"').gsub('&amp;', '&')
671
+ config = begin
672
+ JSON.parse(config_str)
673
+ rescue StandardError
674
+ {}
675
+ end
676
+ render_template(template, config)
677
+ end
678
+
295
679
  def parse_padding(style)
296
680
  if style["padding"]
297
681
  parts = style["padding"].split
@@ -369,14 +753,15 @@ module Sevk
369
753
  inner_start = match.end(0)
370
754
  attrs_str = match[1]
371
755
 
372
- # Find the next close tag after this opening tag
373
- close_pos = result.downcase.index(close_tag.downcase, inner_start)
374
- next unless close_pos
756
+ # Find the next close tag after this opening tag (case-insensitive)
757
+ close_match = result.match(Regexp.new("<\\/#{tag_name}>", Regexp::IGNORECASE), inner_start)
758
+ next unless close_match
375
759
 
760
+ close_pos = close_match.begin(0)
376
761
  inner = result[inner_start...close_pos]
377
762
 
378
- # Check if there's another opening tag inside
379
- next if inner.downcase.include?(open_tag_start.downcase)
763
+ # Check if there's another opening tag inside (case-insensitive)
764
+ next if inner.match?(Regexp.new("<#{tag_name}", Regexp::IGNORECASE))
380
765
 
381
766
  # This is an innermost tag, process it
382
767
  attrs = parse_attributes(attrs_str)
@@ -396,8 +781,8 @@ module Sevk
396
781
 
397
782
  def parse_attributes(attrs_str)
398
783
  attrs = {}
399
- attrs_str.scan(/([\w-]+)=["']([^"']*)["']/) do |key, value|
400
- attrs[key] = value
784
+ attrs_str.scan(/([\w-]+)=(?:"([^"]*)"|'([^']*)')/) do |key, dq_val, sq_val|
785
+ attrs[key] = dq_val.nil? || dq_val.empty? ? (sq_val || '') : dq_val
401
786
  end
402
787
  attrs
403
788
  end
@@ -423,6 +808,8 @@ module Sevk
423
808
  style["width"] = attrs["width"] if attrs["width"]
424
809
  style["height"] = attrs["height"] if attrs["height"]
425
810
  style["max-width"] = attrs["max-width"] if attrs["max-width"]
811
+ style["max-height"] = attrs["max-height"] if attrs["max-height"]
812
+ style["min-width"] = attrs["min-width"] if attrs["min-width"]
426
813
  style["min-height"] = attrs["min-height"] if attrs["min-height"]
427
814
 
428
815
  # Spacing - Padding
@@ -468,6 +855,18 @@ module Sevk
468
855
  style["border-bottom-right-radius"] = attrs["border-bottom-right-radius"] if attrs["border-bottom-right-radius"]
469
856
  end
470
857
 
858
+ # Background image
859
+ if attrs["background-image"]
860
+ style["background-image"] = "url('#{attrs["background-image"]}')"
861
+ style["background-size"] = attrs["background-size"] || "cover"
862
+ style["background-position"] = attrs["background-position"] || "center"
863
+ style["background-repeat"] = attrs["background-repeat"] || "no-repeat"
864
+ else
865
+ style["background-size"] = attrs["background-size"] if attrs["background-size"]
866
+ style["background-position"] = attrs["background-position"] if attrs["background-position"]
867
+ style["background-repeat"] = attrs["background-repeat"] if attrs["background-repeat"]
868
+ end
869
+
471
870
  style
472
871
  end
473
872
 
@@ -476,6 +875,9 @@ module Sevk
476
875
  end
477
876
 
478
877
  def generate_html(content)
878
+ lang = @head_settings.lang.empty? ? "en" : @head_settings.lang
879
+ dir = @head_settings.dir.empty? ? "ltr" : @head_settings.dir
880
+
479
881
  title = @head_settings.title.empty? ? "" : "<title>#{@head_settings.title}</title>"
480
882
 
481
883
  font_links = @head_settings.fonts.map do |font|
@@ -491,17 +893,43 @@ module Sevk
491
893
 
492
894
  <<~HTML
493
895
  <!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
494
- <html lang="en" dir="ltr">
896
+ <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">
495
897
  <head>
496
898
  <meta content="text/html; charset=UTF-8" http-equiv="Content-Type"/>
497
899
  <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
900
+ <meta name="x-apple-disable-message-reformatting"/>
901
+ <meta content="IE=edge" http-equiv="X-UA-Compatible"/>
902
+ <meta name="format-detection" content="telephone=no,address=no,email=no,date=no,url=no"/>
903
+ <!--[if mso]>
904
+ <noscript>
905
+ <xml>
906
+ <o:OfficeDocumentSettings>
907
+ <o:AllowPNG/>
908
+ <o:PixelsPerInch>96</o:PixelsPerInch>
909
+ </o:OfficeDocumentSettings>
910
+ </xml>
911
+ </noscript>
912
+ <![endif]-->
913
+ <style type="text/css">
914
+ #outlook a { padding: 0; }
915
+ body { margin: 0; padding: 0; -webkit-text-size-adjust: 100%; -ms-text-size-adjust: 100%; }
916
+ table, td { border-collapse: collapse; mso-table-lspace: 0pt; mso-table-rspace: 0pt; }
917
+ .sevk-row-table { border-collapse: separate !important; }
918
+ img { border: 0; height: auto; line-height: 100%; outline: none; text-decoration: none; -ms-interpolation-mode: bicubic; }
919
+ @media only screen and (max-width: 479px) {
920
+ .sevk-row-table { width: 100% !important; }
921
+ .sevk-column { display: block !important; width: 100% !important; max-width: 100% !important; }
922
+ }
923
+ </style>
498
924
  #{title}
499
925
  #{font_links}
500
926
  #{styles}
501
927
  </head>
502
- <body style="margin:0;padding:0;font-family:ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;background-color:#ffffff">
928
+ <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;">
929
+ <div aria-roledescription="email" role="article">
503
930
  #{preview_text}
504
931
  #{content}
932
+ </div>
505
933
  </body>
506
934
  </html>
507
935
  HTML
@@ -36,6 +36,14 @@ module Sevk
36
36
  def add_contacts(audience_id, contact_ids)
37
37
  client.post("/audiences/#{audience_id}/contacts", { contactIds: contact_ids })
38
38
  end
39
+
40
+ def list_contacts(audience_id, params = {})
41
+ client.get("/audiences/#{audience_id}/contacts#{build_query_string(params)}")
42
+ end
43
+
44
+ def remove_contact(audience_id, contact_id)
45
+ client.delete("/audiences/#{audience_id}/contacts/#{contact_id}")
46
+ end
39
47
  end
40
48
  end
41
49
  end
@@ -13,6 +13,50 @@ module Sevk
13
13
  def get(id)
14
14
  client.get("/broadcasts/#{id}")
15
15
  end
16
+
17
+ def create(params)
18
+ client.post("/broadcasts", params)
19
+ end
20
+
21
+ def update(id, params)
22
+ client.put("/broadcasts/#{id}", params)
23
+ end
24
+
25
+ def delete(id)
26
+ client.delete("/broadcasts/#{id}")
27
+ end
28
+
29
+ def send(id)
30
+ client.post("/broadcasts/#{id}/send")
31
+ end
32
+
33
+ def cancel(id)
34
+ client.post("/broadcasts/#{id}/cancel")
35
+ end
36
+
37
+ def send_test(id, params)
38
+ client.post("/broadcasts/#{id}/test", params)
39
+ end
40
+
41
+ def get_analytics(id)
42
+ client.get("/broadcasts/#{id}/analytics")
43
+ end
44
+
45
+ def get_status(id)
46
+ client.get("/broadcasts/#{id}/status")
47
+ end
48
+
49
+ def get_emails(id, params = {})
50
+ client.get("/broadcasts/#{id}/emails#{build_query_string(params)}")
51
+ end
52
+
53
+ def estimate_cost(id)
54
+ client.get("/broadcasts/#{id}/estimate-cost")
55
+ end
56
+
57
+ def list_active
58
+ client.get("/broadcasts/active")
59
+ end
16
60
  end
17
61
  end
18
62
  end
@@ -35,6 +35,18 @@ module Sevk
35
35
  def delete(id)
36
36
  client.delete("/contacts/#{id}")
37
37
  end
38
+
39
+ def bulk_update(updates)
40
+ client.put("/contacts/bulk-update", updates)
41
+ end
42
+
43
+ def import_csv(params)
44
+ client.post("/contacts/import", params)
45
+ end
46
+
47
+ def get_events(id)
48
+ client.get("/contacts/#{id}/events")
49
+ end
38
50
  end
39
51
  end
40
52
  end
@@ -13,6 +13,30 @@ module Sevk
13
13
  def get(id)
14
14
  client.get("/domains/#{id}")
15
15
  end
16
+
17
+ def create(params)
18
+ client.post("/domains", params)
19
+ end
20
+
21
+ def update(id, params)
22
+ client.put("/domains/#{id}", params)
23
+ end
24
+
25
+ def delete(id)
26
+ client.delete("/domains/#{id}")
27
+ end
28
+
29
+ def verify(id)
30
+ client.post("/domains/#{id}/verify", {})
31
+ end
32
+
33
+ def get_dns_records(id)
34
+ client.get("/domains/#{id}/dns-records")
35
+ end
36
+
37
+ def get_regions
38
+ client.get("/domains/regions")
39
+ end
16
40
  end
17
41
  end
18
42
  end
@@ -5,7 +5,21 @@ require_relative "base"
5
5
  module Sevk
6
6
  module Resources
7
7
  class Emails < Base
8
- def send(to:, subject:, html:, from:, from_name: nil, reply_to: nil, text: nil, headers: nil, tags: nil)
8
+ # Send an email with optional attachments
9
+ #
10
+ # @param to [String, Array<String>] Recipient email(s)
11
+ # @param subject [String] Email subject
12
+ # @param html [String] HTML content
13
+ # @param from [String] Sender email
14
+ # @param from_name [String, nil] Sender name
15
+ # @param reply_to [String, nil] Reply-to address
16
+ # @param text [String, nil] Plain text content
17
+ # @param headers [Hash, nil] Custom headers
18
+ # @param tags [Array, nil] Tags
19
+ # @param attachments [Array<Hash>, nil] Attachments (max 10, 10MB total)
20
+ # Each attachment: { filename: String, content: String (base64), content_type: String }
21
+ # @return [Hash] Response with :id or :ids
22
+ def send(to:, subject:, html:, from:, from_name: nil, reply_to: nil, text: nil, headers: nil, tags: nil, attachments: nil)
9
23
  body = {
10
24
  to: to,
11
25
  subject: subject,
@@ -17,8 +31,25 @@ module Sevk
17
31
  body[:text] = text if text
18
32
  body[:headers] = headers if headers
19
33
  body[:tags] = tags if tags
34
+ body[:attachments] = attachments if attachments
20
35
  client.post("emails", body)
21
36
  end
37
+
38
+ # Send multiple emails in bulk
39
+ #
40
+ # @param emails [Array<Hash>] Array of email parameter hashes (max 100)
41
+ # @return [Hash] Response with :success, :failed, :ids, :errors
42
+ def send_bulk(emails)
43
+ client.post("emails/bulk", { emails: emails })
44
+ end
45
+
46
+ # Get an email by ID
47
+ #
48
+ # @param id [String] Email ID
49
+ # @return [Hash] Email details
50
+ def get(id)
51
+ client.get("/emails/#{id}")
52
+ end
22
53
  end
23
54
  end
24
55
  end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+
5
+ module Sevk
6
+ module Resources
7
+ class Events < Base
8
+ def list(page: nil, limit: nil, type: nil, contact_id: nil)
9
+ params = { page: page, limit: limit, type: type, contact_id: contact_id }
10
+ client.get("/events#{build_query_string(params)}")
11
+ end
12
+
13
+ def stats
14
+ client.get("/events/stats")
15
+ end
16
+ end
17
+ end
18
+ end
@@ -30,6 +30,14 @@ module Sevk
30
30
  def delete(audience_id, segment_id)
31
31
  client.delete("/audiences/#{audience_id}/segments/#{segment_id}")
32
32
  end
33
+
34
+ def calculate(audience_id, segment_id)
35
+ client.get("/audiences/#{audience_id}/segments/#{segment_id}/calculate")
36
+ end
37
+
38
+ def preview(audience_id, data)
39
+ client.post("/audiences/#{audience_id}/segments/preview", data)
40
+ end
33
41
  end
34
42
  end
35
43
  end
@@ -30,6 +30,18 @@ module Sevk
30
30
  def delete(audience_id, topic_id)
31
31
  client.delete("/audiences/#{audience_id}/topics/#{topic_id}")
32
32
  end
33
+
34
+ def add_contacts(audience_id, topic_id, contact_ids:)
35
+ client.post("/audiences/#{audience_id}/topics/#{topic_id}/contacts", { contactIds: contact_ids })
36
+ end
37
+
38
+ def remove_contact(audience_id, topic_id, contact_id)
39
+ client.delete("/audiences/#{audience_id}/topics/#{topic_id}/contacts/#{contact_id}")
40
+ end
41
+
42
+ def list_contacts(audience_id, topic_id, params = {})
43
+ client.get("/audiences/#{audience_id}/topics/#{topic_id}/contacts#{build_query_string(params)}")
44
+ end
33
45
  end
34
46
  end
35
47
  end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+
5
+ module Sevk
6
+ module Resources
7
+ class Webhooks < Base
8
+ def list(page: nil, limit: nil)
9
+ params = { page: page, limit: limit }
10
+ client.get("/webhooks#{build_query_string(params)}")
11
+ end
12
+
13
+ def get(id)
14
+ client.get("/webhooks/#{id}")
15
+ end
16
+
17
+ def create(params)
18
+ client.post("/webhooks", params)
19
+ end
20
+
21
+ def update(id, params)
22
+ client.put("/webhooks/#{id}", params)
23
+ end
24
+
25
+ def delete(id)
26
+ client.delete("/webhooks/#{id}")
27
+ end
28
+
29
+ def test(id)
30
+ client.post("/webhooks/#{id}/test", {})
31
+ end
32
+
33
+ def list_events
34
+ client.get("/webhooks/events")
35
+ end
36
+ end
37
+ end
38
+ end
data/lib/sevk/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Sevk
4
- VERSION = "0.1.0"
4
+ VERSION = "1.0.0"
5
5
  end
data/lib/sevk.rb CHANGED
@@ -17,6 +17,8 @@ require_relative "sevk/resources/topics"
17
17
  require_relative "sevk/resources/segments"
18
18
  require_relative "sevk/resources/subscriptions"
19
19
  require_relative "sevk/resources/emails"
20
+ require_relative "sevk/resources/webhooks"
21
+ require_relative "sevk/resources/events"
20
22
  require_relative "sevk/markup/renderer"
21
23
 
22
24
  module Sevk
@@ -210,4 +212,48 @@ module Sevk
210
212
  end
211
213
  end
212
214
  end
215
+
216
+ module Webhooks
217
+ class << self
218
+ def list(params = {})
219
+ Sevk.client.webhooks.list(**params)
220
+ end
221
+
222
+ def get(id)
223
+ Sevk.client.webhooks.get(id)
224
+ end
225
+
226
+ def create(params)
227
+ Sevk.client.webhooks.create(params)
228
+ end
229
+
230
+ def update(id, params)
231
+ Sevk.client.webhooks.update(id, params)
232
+ end
233
+
234
+ def delete(id)
235
+ Sevk.client.webhooks.delete(id)
236
+ end
237
+
238
+ def test(id)
239
+ Sevk.client.webhooks.test(id)
240
+ end
241
+
242
+ def list_events
243
+ Sevk.client.webhooks.list_events
244
+ end
245
+ end
246
+ end
247
+
248
+ module Events
249
+ class << self
250
+ def list(params = {})
251
+ Sevk.client.events.list(**params)
252
+ end
253
+
254
+ def stats
255
+ Sevk.client.events.stats
256
+ end
257
+ end
258
+ end
213
259
  end
data/sevk.gemspec CHANGED
@@ -31,6 +31,7 @@ Gem::Specification.new do |spec|
31
31
  spec.add_dependency "faraday", "~> 2.0"
32
32
  spec.add_dependency "faraday-retry", "~> 2.0"
33
33
  spec.add_dependency "nokogiri", "~> 1.15"
34
+ spec.add_dependency "rouge", "~> 4.0"
34
35
 
35
36
  spec.add_development_dependency "bundler", "~> 2.0"
36
37
  spec.add_development_dependency "rake", "~> 13.0"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: sevk
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 1.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Sevk
@@ -51,6 +51,20 @@ dependencies:
51
51
  - - "~>"
52
52
  - !ruby/object:Gem::Version
53
53
  version: '1.15'
54
+ - !ruby/object:Gem::Dependency
55
+ name: rouge
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - "~>"
59
+ - !ruby/object:Gem::Version
60
+ version: '4.0'
61
+ type: :runtime
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - "~>"
66
+ - !ruby/object:Gem::Version
67
+ version: '4.0'
54
68
  - !ruby/object:Gem::Dependency
55
69
  name: bundler
56
70
  requirement: !ruby/object:Gem::Requirement
@@ -141,10 +155,12 @@ files:
141
155
  - lib/sevk/resources/contacts.rb
142
156
  - lib/sevk/resources/domains.rb
143
157
  - lib/sevk/resources/emails.rb
158
+ - lib/sevk/resources/events.rb
144
159
  - lib/sevk/resources/segments.rb
145
160
  - lib/sevk/resources/subscriptions.rb
146
161
  - lib/sevk/resources/templates.rb
147
162
  - lib/sevk/resources/topics.rb
163
+ - lib/sevk/resources/webhooks.rb
148
164
  - lib/sevk/version.rb
149
165
  - sevk.gemspec
150
166
  homepage: https://sevk.io