laneful-ruby 1.0.1

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,442 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Laneful
4
+ # Represents an email address with an optional name
5
+ class Address
6
+ attr_reader :email, :name
7
+
8
+ def initialize(email, name = nil)
9
+ @email = email
10
+ @name = name
11
+ validate!
12
+ end
13
+
14
+ # Creates an Address from a hash representation
15
+ def self.from_hash(data)
16
+ new(data['email'], data['name'])
17
+ end
18
+
19
+ def to_hash
20
+ hash = { 'email' => email }
21
+ hash['name'] = name if name && !name.empty?
22
+ hash
23
+ end
24
+
25
+ def ==(other)
26
+ return false unless other.is_a?(Address)
27
+
28
+ email == other.email && name == other.name
29
+ end
30
+
31
+ def eql?(other)
32
+ self == other
33
+ end
34
+
35
+ def hash
36
+ [email, name].hash
37
+ end
38
+
39
+ def to_s
40
+ if name && !name.strip.empty?
41
+ "#{name} <#{email}>"
42
+ else
43
+ email
44
+ end
45
+ end
46
+
47
+ private
48
+
49
+ def validate!
50
+ raise ValidationException, 'Email address cannot be empty' if email.nil? || email.strip.empty?
51
+
52
+ # Basic email validation
53
+ email_regex = /^[A-Za-z0-9+_.-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$/
54
+ return if email.match?(email_regex)
55
+
56
+ raise ValidationException, "Invalid email address format: #{email}"
57
+ end
58
+ end
59
+
60
+ # Configuration for email tracking settings
61
+ class TrackingSettings
62
+ attr_reader :opens, :clicks, :unsubscribes
63
+
64
+ def initialize(opens: false, clicks: false, unsubscribes: false)
65
+ @opens = opens
66
+ @clicks = clicks
67
+ @unsubscribes = unsubscribes
68
+ end
69
+
70
+ # Creates tracking settings from a hash representation
71
+ def self.from_hash(data)
72
+ new(
73
+ opens: data['opens'] || false,
74
+ clicks: data['clicks'] || false,
75
+ unsubscribes: data['unsubscribes'] || false
76
+ )
77
+ end
78
+
79
+ def to_hash
80
+ {
81
+ 'opens' => opens,
82
+ 'clicks' => clicks,
83
+ 'unsubscribes' => unsubscribes
84
+ }
85
+ end
86
+
87
+ def ==(other)
88
+ return false unless other.is_a?(TrackingSettings)
89
+
90
+ opens == other.opens && clicks == other.clicks && unsubscribes == other.unsubscribes
91
+ end
92
+
93
+ def eql?(other)
94
+ self == other
95
+ end
96
+
97
+ def hash
98
+ [opens, clicks, unsubscribes].hash
99
+ end
100
+
101
+ def to_s
102
+ "TrackingSettings{opens=#{opens}, clicks=#{clicks}, unsubscribes=#{unsubscribes}}"
103
+ end
104
+ end
105
+
106
+ # Represents a file attachment for an email
107
+ class Attachment
108
+ attr_reader :filename, :content_type, :content
109
+
110
+ def initialize(filename, content_type, content)
111
+ @filename = filename
112
+ @content_type = content_type
113
+ @content = content
114
+ validate!
115
+ end
116
+
117
+ # Creates an attachment from a file
118
+ def self.from_file(file_path)
119
+ filename = File.basename(file_path)
120
+ content_type = detect_content_type(file_path)
121
+ content = Base64.strict_encode64(File.binread(file_path))
122
+ new(filename, content_type, content)
123
+ end
124
+
125
+ # Creates an attachment from a hash representation
126
+ def self.from_hash(data)
127
+ new(data['filename'], data['content_type'], data['content'])
128
+ end
129
+
130
+ def to_hash
131
+ {
132
+ 'filename' => filename,
133
+ 'content_type' => content_type,
134
+ 'content' => content
135
+ }
136
+ end
137
+
138
+ def ==(other)
139
+ return false unless other.is_a?(Attachment)
140
+
141
+ filename == other.filename && content_type == other.content_type && content == other.content
142
+ end
143
+
144
+ def eql?(other)
145
+ self == other
146
+ end
147
+
148
+ def hash
149
+ [filename, content_type, content].hash
150
+ end
151
+
152
+ def to_s
153
+ "Attachment{filename='#{filename}', contentType='#{content_type}', contentLength=#{content&.length || 0}}"
154
+ end
155
+
156
+ def self.detect_content_type(file_path)
157
+ # Simple content type detection based on file extension
158
+ case File.extname(file_path).downcase
159
+ when '.pdf'
160
+ 'application/pdf'
161
+ when '.jpg', '.jpeg'
162
+ 'image/jpeg'
163
+ when '.png'
164
+ 'image/png'
165
+ when '.gif'
166
+ 'image/gif'
167
+ when '.txt'
168
+ 'text/plain'
169
+ when '.html', '.htm'
170
+ 'text/html'
171
+ when '.doc'
172
+ 'application/msword'
173
+ when '.docx'
174
+ 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
175
+ when '.xls'
176
+ 'application/vnd.ms-excel'
177
+ when '.xlsx'
178
+ 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
179
+ else
180
+ 'application/octet-stream'
181
+ end
182
+ end
183
+
184
+ private
185
+
186
+ def validate!
187
+ raise ValidationException, 'Filename cannot be empty' if filename.nil? || filename.strip.empty?
188
+ raise ValidationException, 'Content type cannot be empty' if content_type.nil? || content_type.strip.empty?
189
+ raise ValidationException, 'Content cannot be empty' if content.nil? || content.strip.empty?
190
+ end
191
+ end
192
+
193
+ # Represents a single email to be sent
194
+ class Email
195
+ attr_reader :from, :to, :cc, :bcc, :subject, :text_content, :html_content,
196
+ :template_id, :template_data, :attachments, :headers, :reply_to,
197
+ :send_time, :webhook_data, :tag, :tracking
198
+
199
+ def initialize(builder)
200
+ @from = builder.instance_variable_get(:@from)
201
+ @to = builder.instance_variable_get(:@to).dup.freeze
202
+ @cc = builder.instance_variable_get(:@cc).dup.freeze
203
+ @bcc = builder.instance_variable_get(:@bcc).dup.freeze
204
+ @subject = builder.instance_variable_get(:@subject)
205
+ @text_content = builder.instance_variable_get(:@text_content)
206
+ @html_content = builder.instance_variable_get(:@html_content)
207
+ @template_id = builder.instance_variable_get(:@template_id)
208
+ @template_data = builder.instance_variable_get(:@template_data)&.dup&.freeze
209
+ @attachments = builder.instance_variable_get(:@attachments).dup.freeze
210
+ @headers = builder.instance_variable_get(:@headers)&.dup&.freeze
211
+ @reply_to = builder.instance_variable_get(:@reply_to)
212
+ @send_time = builder.instance_variable_get(:@send_time)
213
+ @webhook_data = builder.instance_variable_get(:@webhook_data)&.dup&.freeze
214
+ @tag = builder.instance_variable_get(:@tag)
215
+ @tracking = builder.instance_variable_get(:@tracking)
216
+ validate!
217
+ end
218
+
219
+ # Creates an Email from a hash representation
220
+ def self.from_hash(data)
221
+ builder = Builder.new
222
+
223
+ builder.from(Address.from_hash(data['from'])) if data && data['from']
224
+
225
+ data['to'].each { |to_data| builder.to(Address.from_hash(to_data)) } if data && data['to']
226
+
227
+ data['cc'].each { |cc_data| builder.cc(Address.from_hash(cc_data)) } if data && data['cc']
228
+
229
+ data['bcc'].each { |bcc_data| builder.bcc(Address.from_hash(bcc_data)) } if data && data['bcc']
230
+
231
+ builder.subject(data['subject']) if data && data['subject']
232
+ builder.text_content(data['text_content']) if data && data['text_content']
233
+ builder.html_content(data['html_content']) if data && data['html_content']
234
+ builder.template_id(data['template_id']) if data && data['template_id']
235
+ builder.template_data(data['template_data']) if data && data['template_data']
236
+ builder.headers(data['headers']) if data && data['headers']
237
+ builder.reply_to(Address.from_hash(data['reply_to'])) if data && data['reply_to']
238
+ builder.send_time(data['send_time']) if data && data['send_time']
239
+ builder.webhook_data(data['webhook_data']) if data && data['webhook_data']
240
+ builder.tag(data['tag']) if data && data['tag']
241
+ builder.tracking(TrackingSettings.from_hash(data['tracking'])) if data && data['tracking']
242
+
243
+ if data && data['attachments']
244
+ data['attachments'].each { |attachment_data| builder.attachment(Attachment.from_hash(attachment_data)) }
245
+ end
246
+
247
+ builder.build
248
+ end
249
+
250
+ def to_hash
251
+ hash = {}
252
+
253
+ # Required fields
254
+ hash['from'] = from.to_hash
255
+ hash['to'] = to.map(&:to_hash)
256
+ hash['subject'] = subject if subject
257
+
258
+ # Optional fields (only include if not nil/empty)
259
+ hash['cc'] = cc.map(&:to_hash) unless cc.empty?
260
+ hash['bcc'] = bcc.map(&:to_hash) unless bcc.empty?
261
+ hash['text_content'] = text_content if text_content && !text_content.strip.empty?
262
+ hash['html_content'] = html_content if html_content && !html_content.strip.empty?
263
+ hash['template_id'] = template_id if template_id && !template_id.strip.empty?
264
+ hash['template_data'] = template_data if template_data
265
+ hash['attachments'] = attachments.map(&:to_hash) unless attachments.empty?
266
+ hash['headers'] = headers if headers
267
+ hash['reply_to'] = reply_to.to_hash if reply_to
268
+ hash['send_time'] = send_time if send_time
269
+ hash['webhook_data'] = webhook_data if webhook_data
270
+ hash['tag'] = tag if tag && !tag.strip.empty?
271
+ hash['tracking'] = tracking.to_hash if tracking
272
+
273
+ hash
274
+ end
275
+
276
+ def ==(other)
277
+ return false unless other.is_a?(Email)
278
+
279
+ from == other.from &&
280
+ to == other.to &&
281
+ cc == other.cc &&
282
+ bcc == other.bcc &&
283
+ subject == other.subject &&
284
+ text_content == other.text_content &&
285
+ html_content == other.html_content &&
286
+ template_id == other.template_id &&
287
+ template_data == other.template_data &&
288
+ attachments == other.attachments &&
289
+ headers == other.headers &&
290
+ reply_to == other.reply_to &&
291
+ send_time == other.send_time &&
292
+ webhook_data == other.webhook_data &&
293
+ tag == other.tag &&
294
+ tracking == other.tracking
295
+ end
296
+
297
+ def eql?(other)
298
+ self == other
299
+ end
300
+
301
+ def hash
302
+ [from, to, cc, bcc, subject, text_content, html_content, template_id,
303
+ template_data, attachments, headers, reply_to, send_time, webhook_data, tag, tracking].hash
304
+ end
305
+
306
+ def to_s
307
+ has_text = text_content && !text_content.empty?
308
+ has_html = html_content && !html_content.empty?
309
+
310
+ <<~STR
311
+ Email{
312
+ from=#{from},
313
+ to=#{to},
314
+ subject='#{subject}',
315
+ hasTextContent=#{has_text},
316
+ hasHtmlContent=#{has_html},
317
+ templateId='#{template_id}',
318
+ attachments=#{attachments.size}
319
+ }
320
+ STR
321
+ end
322
+
323
+ private
324
+
325
+ def validate!
326
+ raise ValidationException, 'From address is required' if from.nil?
327
+
328
+ # Must have at least one recipient
329
+ if to.empty? && cc.empty? && bcc.empty?
330
+ raise ValidationException, 'Email must have at least one recipient (to, cc, or bcc)'
331
+ end
332
+
333
+ # Must have either content or template
334
+ has_content = (text_content && !text_content.strip.empty?) ||
335
+ (html_content && !html_content.strip.empty?)
336
+ has_template = template_id && !template_id.strip.empty?
337
+
338
+ unless has_content || has_template
339
+ raise ValidationException, 'Email must have either content (text/HTML) or a template ID'
340
+ end
341
+
342
+ # Validate send time
343
+ return unless send_time && send_time <= Time.now.to_i
344
+
345
+ raise ValidationException, 'Send time must be in the future'
346
+ end
347
+
348
+ # Builder class for creating Email instances
349
+ class Builder
350
+ def initialize
351
+ @to = []
352
+ @cc = []
353
+ @bcc = []
354
+ @attachments = []
355
+ end
356
+
357
+ def from(address)
358
+ @from = address
359
+ self
360
+ end
361
+
362
+ def to(address)
363
+ @to << address
364
+ self
365
+ end
366
+
367
+ def cc(address)
368
+ @cc << address
369
+ self
370
+ end
371
+
372
+ def bcc(address)
373
+ @bcc << address
374
+ self
375
+ end
376
+
377
+ def subject(subject)
378
+ @subject = subject
379
+ self
380
+ end
381
+
382
+ def text_content(content)
383
+ @text_content = content
384
+ self
385
+ end
386
+
387
+ def html_content(content)
388
+ @html_content = content
389
+ self
390
+ end
391
+
392
+ def template_id(id)
393
+ @template_id = id
394
+ self
395
+ end
396
+
397
+ def template_data(data)
398
+ @template_data = data
399
+ self
400
+ end
401
+
402
+ def attachment(attachment)
403
+ @attachments << attachment
404
+ self
405
+ end
406
+
407
+ def headers(headers)
408
+ @headers = headers
409
+ self
410
+ end
411
+
412
+ def reply_to(address)
413
+ @reply_to = address
414
+ self
415
+ end
416
+
417
+ def send_time(time)
418
+ @send_time = time
419
+ self
420
+ end
421
+
422
+ def webhook_data(data)
423
+ @webhook_data = data
424
+ self
425
+ end
426
+
427
+ def tag(tag)
428
+ @tag = tag
429
+ self
430
+ end
431
+
432
+ def tracking(tracking)
433
+ @tracking = tracking
434
+ self
435
+ end
436
+
437
+ def build
438
+ Email.new(self)
439
+ end
440
+ end
441
+ end
442
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Laneful
4
+ VERSION = '1.0.1'
5
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Laneful
4
+ # Utility class for verifying webhook signatures
5
+ class WebhookVerifier
6
+ ALGORITHM = 'sha256'
7
+
8
+ # Verifies a webhook signature
9
+ def self.verify_signature(secret, payload, signature)
10
+ return false if secret.nil? || secret.strip.empty?
11
+ return false if payload.nil?
12
+ return false if signature.nil? || signature.strip.empty?
13
+
14
+ expected_signature = generate_signature(secret, payload)
15
+ secure_compare?(expected_signature, signature)
16
+ rescue StandardError
17
+ false
18
+ end
19
+
20
+ # Generates a signature for the given payload
21
+ def self.generate_signature(secret, payload)
22
+ digest = OpenSSL::Digest.new(ALGORITHM)
23
+ hmac = OpenSSL::HMAC.new(secret, digest)
24
+ hmac.update(payload)
25
+ hmac.hexdigest
26
+ end
27
+
28
+ # Compares two strings in constant time to prevent timing attacks
29
+ def self.secure_compare?(str_a, str_b)
30
+ return false unless str_a.bytesize == str_b.bytesize
31
+
32
+ l = str_a.unpack("C#{str_a.bytesize}")
33
+ res = 0
34
+ str_b.each_byte { |byte| res |= byte ^ l.shift }
35
+ res.zero?
36
+ end
37
+ end
38
+ end
data/lib/laneful.rb ADDED
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'httparty'
5
+ require 'openssl'
6
+ require 'base64'
7
+
8
+ require_relative 'laneful/version'
9
+ require_relative 'laneful/exceptions'
10
+ require_relative 'laneful/models'
11
+ require_relative 'laneful/client'
12
+ require_relative 'laneful/webhooks'
13
+
14
+ # Laneful Ruby SDK
15
+ # A modern Ruby client library for the Laneful email API
16
+ module Laneful
17
+ class Error < StandardError; end
18
+
19
+ # API version
20
+ API_VERSION = 'v1'
21
+
22
+ # Default timeout in seconds
23
+ DEFAULT_TIMEOUT = 30
24
+
25
+ # User agent string
26
+ USER_AGENT = 'laneful-ruby/1.0.0'
27
+ end
@@ -0,0 +1,169 @@
1
+ #!/bin/bash
2
+
3
+ # Laneful Ruby SDK Publishing Script
4
+ # This script handles the complete publishing workflow to RubyGems
5
+
6
+ set -e
7
+
8
+ # Colors for output
9
+ RED='\033[0;31m'
10
+ GREEN='\033[0;32m'
11
+ YELLOW='\033[1;33m'
12
+ BLUE='\033[0;34m'
13
+ NC='\033[0m' # No Color
14
+
15
+ # Function to print colored output
16
+ print_status() {
17
+ echo -e "${BLUE}[INFO]${NC} $1"
18
+ }
19
+
20
+ print_success() {
21
+ echo -e "${GREEN}[SUCCESS]${NC} $1"
22
+ }
23
+
24
+ print_warning() {
25
+ echo -e "${YELLOW}[WARNING]${NC} $1"
26
+ }
27
+
28
+ print_error() {
29
+ echo -e "${RED}[ERROR]${NC} $1"
30
+ }
31
+
32
+ # Check if required environment variables are set
33
+ check_environment() {
34
+ print_status "Checking environment variables..."
35
+
36
+ if [ -z "$GEM_HOST_API_KEY" ]; then
37
+ print_error "GEM_HOST_API_KEY environment variable is not set"
38
+ print_status "Please set your RubyGems API key:"
39
+ print_status "export GEM_HOST_API_KEY=your_api_key_here"
40
+ exit 1
41
+ fi
42
+
43
+ print_success "Environment variables are set"
44
+ }
45
+
46
+ # Check if we're in the right directory
47
+ check_directory() {
48
+ if [ ! -f "laneful-ruby.gemspec" ]; then
49
+ print_error "laneful-ruby.gemspec not found. Please run this script from the project root."
50
+ exit 1
51
+ fi
52
+ print_success "Found gemspec file"
53
+ }
54
+
55
+ # Check Ruby version
56
+ check_ruby_version() {
57
+ print_status "Checking Ruby version..."
58
+ ruby_version=$(ruby -v | cut -d' ' -f2)
59
+ required_version="3.0.0"
60
+
61
+ if [ "$(printf '%s\n' "$required_version" "$ruby_version" | sort -V | head -n1)" = "$required_version" ]; then
62
+ print_success "Ruby version $ruby_version is compatible"
63
+ else
64
+ print_error "Ruby version $ruby_version is not compatible. Required: $required_version or higher"
65
+ exit 1
66
+ fi
67
+ }
68
+
69
+ # Install dependencies
70
+ install_dependencies() {
71
+ print_status "Installing dependencies..."
72
+ bundle install
73
+ if [ $? -eq 0 ]; then
74
+ print_success "Dependencies installed successfully"
75
+ else
76
+ print_error "Failed to install dependencies. Aborting publish."
77
+ exit 1
78
+ fi
79
+ }
80
+
81
+ # Run tests
82
+ run_tests() {
83
+ print_status "Running tests..."
84
+ bundle exec rspec
85
+ if [ $? -eq 0 ]; then
86
+ print_success "All tests passed"
87
+ else
88
+ print_error "Tests failed. Aborting publish."
89
+ exit 1
90
+ fi
91
+ }
92
+
93
+ # Run linting
94
+ run_lint() {
95
+ print_status "Running linter..."
96
+ bundle exec rubocop
97
+ if [ $? -eq 0 ]; then
98
+ print_success "Linting passed"
99
+ else
100
+ print_error "Linting failed. Aborting publish."
101
+ exit 1
102
+ fi
103
+ }
104
+
105
+ # Build the gem
106
+ build_gem() {
107
+ print_status "Building gem..."
108
+ gem build laneful-ruby.gemspec
109
+ if [ $? -eq 0 ]; then
110
+ print_success "Gem built successfully"
111
+ else
112
+ print_error "Gem build failed. Aborting publish."
113
+ exit 1
114
+ fi
115
+ }
116
+
117
+ # Publish to RubyGems
118
+ publish_gem() {
119
+ print_status "Publishing to RubyGems..."
120
+ gem push laneful-ruby-*.gem
121
+ if [ $? -eq 0 ]; then
122
+ print_success "Gem published successfully to RubyGems!"
123
+ else
124
+ print_error "Failed to publish gem to RubyGems."
125
+ exit 1
126
+ fi
127
+ }
128
+
129
+ # Clean up
130
+ cleanup() {
131
+ print_status "Cleaning up..."
132
+ rm -f laneful-ruby-*.gem
133
+ print_success "Cleanup completed"
134
+ }
135
+
136
+ # Main execution
137
+ main() {
138
+ print_status "Starting Laneful Ruby SDK publishing process..."
139
+
140
+ check_environment
141
+ check_directory
142
+ check_ruby_version
143
+ install_dependencies
144
+
145
+ # Ask for confirmation
146
+ echo
147
+ print_warning "This will publish the gem to RubyGems. Are you sure? (y/N)"
148
+ read -r response
149
+ if [[ ! "$response" =~ ^[Yy]$ ]]; then
150
+ print_status "Publishing cancelled."
151
+ exit 0
152
+ fi
153
+
154
+ run_tests
155
+ run_lint
156
+ build_gem
157
+ publish_gem
158
+ cleanup
159
+
160
+ print_success "Publishing process completed successfully!"
161
+ print_status "Your gem is now available on RubyGems: https://rubygems.org/gems/laneful-ruby"
162
+ }
163
+
164
+ # Handle script interruption
165
+ trap cleanup EXIT
166
+
167
+ # Run main function
168
+ main "$@"
169
+