mailblastr 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.
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mailblastr
4
+ # Segments are DOMAIN-FIRST: `domain` is required on create and list. Names
5
+ # are unique within a domain (reusable across domains); every domain also
6
+ # carries an auto-created "General" (all contacts) segment.
7
+ module Segments
8
+ class << self
9
+ # POST /segments
10
+ # Mailblastr::Segments.create({ domain: "yourdomain.com", name: "VIP",
11
+ # filter: { status: "subscribed" } })
12
+ def create(params)
13
+ Client.require_domain!(params, "Segments.create")
14
+ Client.request(:post, "/segments", body: params)
15
+ end
16
+
17
+ # GET /segments/:id
18
+ def get(segment_id)
19
+ Client.request(:get, "/segments/#{Client.path_escape(segment_id)}")
20
+ end
21
+
22
+ # List a domain's segments (`domain` required). GET /segments?domain=
23
+ def list(params)
24
+ domain = Client.require_domain!(params, "Segments.list")
25
+ Client.request(:get, "/segments", query: { domain: domain }.merge(Client.pagination(params)))
26
+ end
27
+
28
+ # Preview the contacts a segment currently resolves to. GET /segments/:id/contacts
29
+ def contacts(segment_id)
30
+ Client.request(:get, "/segments/#{Client.path_escape(segment_id)}/contacts")
31
+ end
32
+
33
+ # PATCH /segments/:id
34
+ def update(segment_id, params)
35
+ Client.request(:patch, "/segments/#{Client.path_escape(segment_id)}", body: params)
36
+ end
37
+
38
+ # DELETE /segments/:id
39
+ def delete(segment_id)
40
+ Client.request(:delete, "/segments/#{Client.path_escape(segment_id)}")
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mailblastr
4
+ module Templates
5
+ class << self
6
+ # POST /templates — params: { name:, alias:, subject:, html:, text:, variables: [...] }
7
+ def create(params)
8
+ Client.request(:post, "/templates", body: params)
9
+ end
10
+
11
+ # GET /templates/:id (an alias works anywhere an id is accepted)
12
+ def get(template_id)
13
+ Client.request(:get, "/templates/#{Client.path_escape(template_id)}")
14
+ end
15
+
16
+ # GET /templates
17
+ def list(params = {})
18
+ Client.request(:get, "/templates", query: Client.pagination(params))
19
+ end
20
+
21
+ # PATCH /templates/:id
22
+ def update(template_id, params)
23
+ Client.request(:patch, "/templates/#{Client.path_escape(template_id)}", body: params)
24
+ end
25
+
26
+ # Duplicate a template. POST /templates/:id/duplicate — params: { name:, alias: }
27
+ def duplicate(template_id, params = {})
28
+ Client.request(:post, "/templates/#{Client.path_escape(template_id)}/duplicate", body: params)
29
+ end
30
+
31
+ # Publish a template (make its latest draft live). POST /templates/:id/publish
32
+ def publish(template_id)
33
+ Client.request(:post, "/templates/#{Client.path_escape(template_id)}/publish")
34
+ end
35
+
36
+ # DELETE /templates/:id
37
+ def delete(template_id)
38
+ Client.request(:delete, "/templates/#{Client.path_escape(template_id)}")
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mailblastr
4
+ # Topics (granular subscriptions) are DOMAIN-FIRST: `domain` is required on
5
+ # create and list. Topic names are reusable across domains.
6
+ module Topics
7
+ class << self
8
+ # POST /topics
9
+ # Mailblastr::Topics.create({ domain: "yourdomain.com", name: "Product updates",
10
+ # default_subscription: "opt_in" })
11
+ def create(params)
12
+ Client.require_domain!(params, "Topics.create")
13
+ Client.request(:post, "/topics", body: params)
14
+ end
15
+
16
+ # GET /topics/:id
17
+ def get(topic_id)
18
+ Client.request(:get, "/topics/#{Client.path_escape(topic_id)}")
19
+ end
20
+
21
+ # List a domain's topics (`domain` required). GET /topics?domain=
22
+ def list(params)
23
+ domain = Client.require_domain!(params, "Topics.list")
24
+ Client.request(:get, "/topics", query: { domain: domain }.merge(Client.pagination(params)))
25
+ end
26
+
27
+ # PATCH /topics/:id
28
+ def update(topic_id, params)
29
+ Client.request(:patch, "/topics/#{Client.path_escape(topic_id)}", body: params)
30
+ end
31
+
32
+ # DELETE /topics/:id
33
+ def delete(topic_id)
34
+ Client.request(:delete, "/topics/#{Client.path_escape(topic_id)}")
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mailblastr
4
+ VERSION = "1.0.0"
5
+ end
@@ -0,0 +1,130 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "openssl"
4
+
5
+ module Mailblastr
6
+ module Webhooks
7
+ class << self
8
+ # Create a webhook. The plaintext signing secret is returned ONCE, only here.
9
+ # POST /webhooks — params: { endpoint:, events: [...], secret: }
10
+ def create(params)
11
+ Client.request(:post, "/webhooks", body: params)
12
+ end
13
+
14
+ # GET /webhooks/:id
15
+ def get(webhook_id)
16
+ Client.request(:get, "/webhooks/#{Client.path_escape(webhook_id)}")
17
+ end
18
+
19
+ # GET /webhooks
20
+ def list(params = {})
21
+ Client.request(:get, "/webhooks", query: Client.pagination(params))
22
+ end
23
+
24
+ # PATCH /webhooks/:id — params: { endpoint:, events:, status: }
25
+ def update(webhook_id, params)
26
+ Client.request(:patch, "/webhooks/#{Client.path_escape(webhook_id)}", body: params)
27
+ end
28
+
29
+ # Rotate the signing secret; the new plaintext secret is returned once and
30
+ # the old one stops verifying immediately. POST /webhooks/:id/rotate
31
+ def rotate(webhook_id)
32
+ Client.request(:post, "/webhooks/#{Client.path_escape(webhook_id)}/rotate")
33
+ end
34
+
35
+ # Send a synchronous test delivery and return the endpoint's live result.
36
+ # POST /webhooks/:id/test
37
+ def test(webhook_id)
38
+ Client.request(:post, "/webhooks/#{Client.path_escape(webhook_id)}/test")
39
+ end
40
+
41
+ # DELETE /webhooks/:id
42
+ def delete(webhook_id)
43
+ Client.request(:delete, "/webhooks/#{Client.path_escape(webhook_id)}")
44
+ end
45
+
46
+ # Verify a webhook delivery's Svix-style signature against your endpoint's
47
+ # signing secret. Pure local computation (OpenSSL HMAC-SHA256) — no HTTP.
48
+ #
49
+ # `payload` MUST be the exact raw request body string (do not re-serialize
50
+ # parsed JSON). `headers` is a Hash carrying svix-id / svix-timestamp /
51
+ # svix-signature (read case-insensitively; array values use the first
52
+ # element). The signature header may carry multiple space-separated
53
+ # `v1,<base64>` entries — any one match makes the delivery valid.
54
+ #
55
+ # Returns { valid: true } or { valid: false, reason: "..." }.
56
+ #
57
+ # result = Mailblastr::Webhooks.verify_signature(request.raw_post, request.headers.to_h, secret)
58
+ # head :unauthorized unless result[:valid]
59
+ def verify_signature(payload, headers, secret, tolerance: 300)
60
+ id = read_header(headers, "svix-id")
61
+ timestamp = read_header(headers, "svix-timestamp")
62
+ sig_header = read_header(headers, "svix-signature")
63
+ return { valid: false, reason: "missing_headers" } if id.nil? || timestamp.nil? || sig_header.nil?
64
+ return { valid: false, reason: "missing_secret" } if secret.nil? || secret.to_s.empty?
65
+
66
+ # Optional timestamp freshness check (default 5 minutes; 0 disables).
67
+ if tolerance && tolerance.positive?
68
+ ts = begin
69
+ Integer(timestamp, 10)
70
+ rescue ArgumentError, TypeError
71
+ nil
72
+ end
73
+ return { valid: false, reason: "invalid_timestamp" } if ts.nil?
74
+ return { valid: false, reason: "timestamp_out_of_tolerance" } if (Time.now.to_i - ts).abs > tolerance
75
+ end
76
+
77
+ signed = "#{id}.#{timestamp}.#{payload}"
78
+ digest = OpenSSL::HMAC.digest("SHA256", secret_to_key(secret), signed)
79
+ expected = [digest].pack("m0") # strict base64, no newline
80
+
81
+ sig_header.split(" ").each do |part|
82
+ part = part.strip
83
+ next if part.empty?
84
+
85
+ sig = part.start_with?("v1,") ? part[3..] : part
86
+ return { valid: true } if secure_compare(sig, expected)
87
+ end
88
+ { valid: false, reason: "no_match" }
89
+ end
90
+
91
+ private
92
+
93
+ # Case-insensitively read one header value (first element if an array).
94
+ def read_header(headers, name)
95
+ return nil unless headers.is_a?(Hash)
96
+
97
+ lower = name.downcase
98
+ headers.each do |k, v|
99
+ next unless k.to_s.downcase == lower
100
+
101
+ return v.is_a?(Array) ? v.first : v
102
+ end
103
+ nil
104
+ end
105
+
106
+ # Derive the HMAC key from a `whsec_`-prefixed secret (base64-decode the
107
+ # suffix); a secret without the prefix is used as raw UTF-8 bytes.
108
+ def secret_to_key(secret)
109
+ s = secret.to_s
110
+ if s.start_with?("whsec_")
111
+ decoded = s["whsec_".length..].unpack1("m") # lenient base64 decode
112
+ return decoded if decoded && !decoded.empty?
113
+ end
114
+ s
115
+ end
116
+
117
+ # Constant-time compare of two signature strings.
118
+ def secure_compare(a, b)
119
+ a = a.to_s
120
+ b = b.to_s
121
+ return false unless a.bytesize == b.bytesize
122
+
123
+ l = a.unpack("C*")
124
+ res = 0
125
+ b.each_byte.with_index { |byte, i| res |= byte ^ l[i] }
126
+ res.zero?
127
+ end
128
+ end
129
+ end
130
+ end
data/lib/mailblastr.rb ADDED
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "mailblastr/version"
4
+ require "mailblastr/error"
5
+ require "mailblastr/client"
6
+ require "mailblastr/emails"
7
+ require "mailblastr/domains"
8
+ require "mailblastr/audiences"
9
+ require "mailblastr/contacts"
10
+ require "mailblastr/contact_properties"
11
+ require "mailblastr/segments"
12
+ require "mailblastr/topics"
13
+ require "mailblastr/campaigns"
14
+ require "mailblastr/templates"
15
+ require "mailblastr/automations"
16
+ require "mailblastr/webhooks"
17
+ require "mailblastr/events"
18
+ require "mailblastr/api_keys"
19
+ require "mailblastr/logs"
20
+ require "mailblastr/polls"
21
+
22
+ # The MailBlastr API client.
23
+ #
24
+ # Mailblastr.api_key = "mb_xxxxxxxxx"
25
+ #
26
+ # sent = Mailblastr::Emails.send({
27
+ # from: "Acme <hello@yourdomain.com>",
28
+ # to: ["user@example.com"],
29
+ # subject: "Hello from MailBlastr",
30
+ # html: "<p>Your first email</p>"
31
+ # })
32
+ # sent["id"] # => "..."
33
+ module Mailblastr
34
+ DEFAULT_BASE_URL = "https://www.mailblastr.com/api"
35
+
36
+ # Per-request network timeout, in seconds, applied to both the connect
37
+ # (open) and read phases. 0 or nil means "no timeout".
38
+ DEFAULT_TIMEOUT = 30
39
+
40
+ # How many times a retryable response (HTTP 429/503) is retried before
41
+ # giving up. 0 disables retries (a single attempt).
42
+ DEFAULT_MAX_RETRIES = 2
43
+
44
+ class << self
45
+ # Your API key, e.g. "mb_xxxxxxxxx". Required before any call.
46
+ attr_accessor :api_key
47
+
48
+ # Override the API host (defaults to https://www.mailblastr.com/api).
49
+ attr_writer :base_url
50
+
51
+ # Per-request timeout in seconds (default 30). Set 0 (or nil) for none.
52
+ attr_writer :timeout
53
+
54
+ # Max automatic retries on HTTP 429/503 (default 2, i.e. up to 3 tries).
55
+ attr_writer :max_retries
56
+
57
+ def base_url
58
+ @base_url || DEFAULT_BASE_URL
59
+ end
60
+
61
+ def timeout
62
+ @timeout.nil? ? DEFAULT_TIMEOUT : @timeout
63
+ end
64
+
65
+ def max_retries
66
+ @max_retries.nil? ? DEFAULT_MAX_RETRIES : @max_retries
67
+ end
68
+
69
+ # Mailblastr.configure do |config|
70
+ # config.api_key = ENV["MAILBLASTR_API_KEY"]
71
+ # end
72
+ def configure
73
+ yield self
74
+ end
75
+ end
76
+ end
metadata ADDED
@@ -0,0 +1,83 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: mailblastr
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.0
5
+ platform: ruby
6
+ authors:
7
+ - MailBlastr
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-07-24 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: minitest
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '5.0'
20
+ type: :development
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '5.0'
27
+ description: 'Send transactional and marketing email from your own verified domain:
28
+ emails, domains, contacts, segments, campaigns, templates, automations, webhooks,
29
+ events and more. Zero runtime dependencies (Net::HTTP only).'
30
+ email:
31
+ - support@mailblastr.com
32
+ executables: []
33
+ extensions: []
34
+ extra_rdoc_files: []
35
+ files:
36
+ - LICENSE
37
+ - README.md
38
+ - lib/mailblastr.rb
39
+ - lib/mailblastr/api_keys.rb
40
+ - lib/mailblastr/audiences.rb
41
+ - lib/mailblastr/automations.rb
42
+ - lib/mailblastr/campaigns.rb
43
+ - lib/mailblastr/client.rb
44
+ - lib/mailblastr/contact_properties.rb
45
+ - lib/mailblastr/contacts.rb
46
+ - lib/mailblastr/domains.rb
47
+ - lib/mailblastr/emails.rb
48
+ - lib/mailblastr/error.rb
49
+ - lib/mailblastr/events.rb
50
+ - lib/mailblastr/logs.rb
51
+ - lib/mailblastr/polls.rb
52
+ - lib/mailblastr/segments.rb
53
+ - lib/mailblastr/templates.rb
54
+ - lib/mailblastr/topics.rb
55
+ - lib/mailblastr/version.rb
56
+ - lib/mailblastr/webhooks.rb
57
+ homepage: https://www.mailblastr.com
58
+ licenses:
59
+ - MIT
60
+ metadata:
61
+ homepage_uri: https://www.mailblastr.com
62
+ documentation_uri: https://www.mailblastr.com/docs
63
+ rubygems_mfa_required: 'true'
64
+ post_install_message:
65
+ rdoc_options: []
66
+ require_paths:
67
+ - lib
68
+ required_ruby_version: !ruby/object:Gem::Requirement
69
+ requirements:
70
+ - - ">="
71
+ - !ruby/object:Gem::Version
72
+ version: '2.7'
73
+ required_rubygems_version: !ruby/object:Gem::Requirement
74
+ requirements:
75
+ - - ">="
76
+ - !ruby/object:Gem::Version
77
+ version: '0'
78
+ requirements: []
79
+ rubygems_version: 3.5.22
80
+ signing_key:
81
+ specification_version: 4
82
+ summary: Official Ruby SDK for the MailBlastr email API
83
+ test_files: []