mailtea 0.2.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,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+
5
+ module Mailtea
6
+ # Sentinel for "this keyword was not given".
7
+ #
8
+ # It exists because +nil+ already means something on the wire: the API clears a
9
+ # nullable field when it receives an explicit JSON +null+, and leaves it alone
10
+ # when the key is absent. A keyword defaulting to +nil+ would collapse those
11
+ # two, so every optional keyword defaults to UNSET and is dropped from the
12
+ # payload; pass +nil+ deliberately and it is sent as +null+.
13
+ #
14
+ # mailtea.segments.update(id, publication_id: pub) # leaves the filter alone
15
+ # mailtea.segments.update(id, publication_id: pub, status_filter: nil) # clears it
16
+ UNSET = Object.new
17
+ def UNSET.inspect
18
+ "Mailtea::UNSET"
19
+ end
20
+ UNSET.freeze
21
+
22
+ # Internal helpers shared by the resource classes. Not part of the public API.
23
+ module Util
24
+ module_function
25
+
26
+ # Merge a wire-format Hash with keyword arguments into one payload.
27
+ #
28
+ # Sources are applied left to right, so keywords win over the Hash — the same
29
+ # precedence the Python SDK uses. Keys are stringified, so
30
+ # <tt>send({"from" => a}, from: b)</tt> is one key, not two.
31
+ def payload(*sources)
32
+ merged = {}
33
+ sources.each do |source|
34
+ next if source.nil?
35
+
36
+ source.each do |key, value|
37
+ next if value.equal?(UNSET)
38
+
39
+ merged[key.to_s] = value
40
+ end
41
+ end
42
+ merged
43
+ end
44
+
45
+ # Render "?a=1&b=2" from a Hash, dropping nil and UNSET values.
46
+ #
47
+ # Returns "" for an empty result so it can be appended to a path
48
+ # unconditionally. Array values become repeated keys (+a=1&a=2+); booleans
49
+ # render as +true+/+false+, which is what the API's query parsers read.
50
+ def query(params)
51
+ return "" if params.nil? || params.empty?
52
+
53
+ pairs = params.each_with_object([]) do |(key, value), acc|
54
+ next if value.nil? || value.equal?(UNSET)
55
+
56
+ acc << [key.to_s, value]
57
+ end
58
+ pairs.empty? ? "" : "?#{URI.encode_www_form(pairs)}"
59
+ end
60
+
61
+ # Percent-encode one path segment. Every reserved character is escaped
62
+ # (including "/"), so an id containing a slash cannot walk to another route.
63
+ def escape(value)
64
+ value.to_s.gsub(/[^A-Za-z0-9\-._~]/) do |char|
65
+ char.bytes.map { |byte| format("%%%02X", byte) }.join
66
+ end
67
+ end
68
+
69
+ # Split a payload into the publication_id that several endpoints want in the
70
+ # query string and the rest of the body. Returns [query_string, body].
71
+ def split_publication(merged, keep_in_body: true)
72
+ publication_id = merged["publication_id"]
73
+ body = keep_in_body ? merged : merged.reject { |key, _| key == "publication_id" }
74
+ [query({ "publication_id" => publication_id }), body]
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mailtea
4
+ VERSION = "0.2.0"
5
+ end
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "openssl"
4
+
5
+ # Standard Webhooks (standardwebhooks.com) signature verification.
6
+ #
7
+ # A stdlib-only mirror of the Mailtea signer, kept in exact parity so a signature
8
+ # produced by the platform verifies here byte-for-byte.
9
+ #
10
+ # The stored signing secret is <tt>whsec_<base64></tt>; the HMAC key is the
11
+ # base64 remainder decoded to bytes. The signed content is
12
+ # <tt>{msg_id}.{timestamp}.{payload}</tt> where +timestamp+ is Unix SECONDS,
13
+ # matching the +webhook-timestamp+ header. The +webhook-signature+ header is
14
+ # <tt>v1,<base64 HMAC-SHA256></tt>; during key rotation it may carry several
15
+ # space-delimited <tt>v1,<sig></tt> tokens and a match against any one of them
16
+ # passes.
17
+ module Mailtea
18
+ SECRET_PREFIX = "whsec_"
19
+ SIGNATURE_VERSION = "v1"
20
+ DEFAULT_TOLERANCE_SECONDS = 300
21
+
22
+ module_function
23
+
24
+ # Sign a webhook payload.
25
+ #
26
+ # Returns the +webhook-signature+ header value in Standard Webhooks form,
27
+ # <tt>v1,<base64 HMAC-SHA256></tt>. Useful for faking Mailtea deliveries in
28
+ # tests.
29
+ #
30
+ # +timestamp+ is Unix seconds — the same value sent in +webhook-timestamp+.
31
+ def sign_webhook(secret, msg_id, timestamp, payload)
32
+ "#{SIGNATURE_VERSION},#{compute_signature(secret, msg_id, timestamp.to_i, payload)}"
33
+ end
34
+
35
+ # Verify a +webhook-signature+ header against the expected HMAC.
36
+ #
37
+ # The header may carry multiple space-delimited <tt>v1,<sig></tt> tokens
38
+ # (Standard Webhooks allows key rotation — the platform may sign a delivery
39
+ # with both the old and new secret); a match against any +v1+ token passes.
40
+ #
41
+ # Returns false when the timestamp is outside +tolerance_seconds+ of +now+
42
+ # (replay protection). Uses a constant-time comparison. Never raises on a bad
43
+ # signature — it returns false.
44
+ #
45
+ # [secret] the endpoint's signing secret (+whsec_...+).
46
+ # [msg_id] the +webhook-id+ header value.
47
+ # [timestamp] the +webhook-timestamp+ header value (Unix seconds; the
48
+ # String the header arrives as is accepted and coerced).
49
+ # [payload] the raw request body, exactly as received.
50
+ # [signature_header] the +webhook-signature+ header value.
51
+ # [tolerance_seconds] allowed clock skew each way. Default 5 minutes.
52
+ # [now] injectable current time (Unix seconds) for tests.
53
+ #
54
+ # ok = Mailtea.verify_webhook_signature(
55
+ # signing_secret,
56
+ # request.headers["webhook-id"],
57
+ # request.headers["webhook-timestamp"],
58
+ # request.raw_post,
59
+ # request.headers["webhook-signature"]
60
+ # )
61
+ def verify_webhook_signature(secret, msg_id, timestamp, payload, signature_header,
62
+ tolerance_seconds: DEFAULT_TOLERANCE_SECONDS, now: nil)
63
+ timestamp_seconds = coerce_timestamp(timestamp)
64
+ return false if timestamp_seconds.nil?
65
+
66
+ now_seconds = (now.nil? ? Time.now.to_i : now.to_i)
67
+ return false if (now_seconds - timestamp_seconds).abs > tolerance_seconds
68
+
69
+ expected = compute_signature(secret, msg_id, timestamp_seconds, payload)
70
+
71
+ signature_header.to_s.split(" ").any? do |token|
72
+ version, _, signature = token.partition(",")
73
+ # OpenSSL's comparison is length-safe and constant-time; == on Strings is
74
+ # neither, and a byte-by-byte early exit is a timing oracle on the digest.
75
+ version == SIGNATURE_VERSION &&
76
+ !signature.empty? &&
77
+ OpenSSL.secure_compare(signature, expected)
78
+ end
79
+ end
80
+
81
+ # Decode the HMAC key from a +whsec_+-prefixed secret.
82
+ #
83
+ # Matches Node's lenient base64 decoder: accepts the base64url alphabet and
84
+ # tolerates missing padding, so a secret minted with either alphabet decodes to
85
+ # the same bytes.
86
+ def decode_signing_key(secret)
87
+ raw = secret.start_with?(SECRET_PREFIX) ? secret[SECRET_PREFIX.length..] : secret
88
+ raw = raw.strip.tr("-_", "+/")
89
+ raw += "=" * ((4 - (raw.length % 4)) % 4)
90
+ raw.unpack1("m")
91
+ end
92
+
93
+ def compute_signature(secret, msg_id, timestamp, payload)
94
+ signed_content = "#{msg_id}.#{timestamp}.#{payload}"
95
+ digest = OpenSSL::HMAC.digest("SHA256", decode_signing_key(secret), signed_content)
96
+ [digest].pack("m0")
97
+ end
98
+
99
+ # A header value is a String, and String#to_i turns "abc" into 0 — which would
100
+ # be inside no tolerance window but is still a value, so the parse has to fail
101
+ # loudly rather than round to the epoch.
102
+ def coerce_timestamp(timestamp)
103
+ return timestamp.floor if timestamp.is_a?(Integer)
104
+ return nil if timestamp.is_a?(Float) && !timestamp.finite?
105
+ return timestamp.floor if timestamp.is_a?(Numeric)
106
+
107
+ Float(timestamp.to_s).floor
108
+ rescue ArgumentError, TypeError, FloatDomainError
109
+ nil
110
+ end
111
+
112
+ private_class_method :decode_signing_key, :compute_signature, :coerce_timestamp
113
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "resource"
4
+
5
+ module Mailtea
6
+ # The +webhooks+ resource (outbound event subscriptions). Reach it at
7
+ # <tt>mailtea.webhooks</tt>.
8
+ #
9
+ # Scoped to a publication — pass +publication_id+. #create returns the
10
+ # +signing_secret+ once; store it and verify deliveries with
11
+ # Mailtea.verify_webhook_signature.
12
+ class Webhooks < Resource
13
+ BASE = "/v1/webhooks/endpoints"
14
+
15
+ # Create an endpoint. Takes +publication_id+, +url+ and +events+.
16
+ def create(params = nil, **fields)
17
+ request("POST", BASE, payload(params, fields))
18
+ end
19
+
20
+ # List endpoints. Requires +publication_id+.
21
+ def list(params = nil, **filters)
22
+ request("GET", BASE + query(payload(params, filters)))
23
+ end
24
+
25
+ # Retrieve an endpoint. Requires +publication_id+.
26
+ def get(id, params = nil, **filters)
27
+ request("GET", BASE + "/" + escape(id) + query(payload(params, filters)))
28
+ end
29
+
30
+ # Update an endpoint's +url+, +events+ or +enabled+. +publication_id+ is
31
+ # required and goes in the query string.
32
+ def update(id, params = nil, **fields)
33
+ scope, body = Util.split_publication(payload(params, fields))
34
+ request("PATCH", BASE + "/" + escape(id) + scope, body)
35
+ end
36
+
37
+ # Delete an endpoint. Requires +publication_id+.
38
+ def delete(id, params = nil, **filters)
39
+ request("DELETE", BASE + "/" + escape(id) + query(payload(params, filters)))
40
+ end
41
+ end
42
+ end
data/lib/mailtea.rb ADDED
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ # The official Ruby SDK for Mailtea — send, schedule, and manage email from your
4
+ # app or AI agent.
5
+ #
6
+ # require "mailtea"
7
+ #
8
+ # mailtea = Mailtea::Client.new
9
+ # mailtea.emails.send(
10
+ # from: "you@yourdomain.com",
11
+ # to: "recipient@example.com",
12
+ # subject: "Hello from Mailtea",
13
+ # html: "<p>Your first email, sent with Mailtea.</p>"
14
+ # )
15
+ #
16
+ # See Mailtea::Client for the resources, Mailtea::Error for what a failure
17
+ # carries, and Mailtea.verify_webhook_signature for inbound webhooks.
18
+ module Mailtea
19
+ end
20
+
21
+ require_relative "mailtea/version"
22
+ require_relative "mailtea/error"
23
+ require_relative "mailtea/response"
24
+ require_relative "mailtea/client"
25
+ require_relative "mailtea/webhook_signing"
metadata ADDED
@@ -0,0 +1,78 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: mailtea
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.2.0
5
+ platform: ruby
6
+ authors:
7
+ - Mailtea
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-09-06 00:00:00.000000000 Z
12
+ dependencies: []
13
+ description: 'The official Ruby SDK for Mailtea: a thin, zero-dependency wrapper over
14
+ the Mailtea REST API, built on net/http and json.'
15
+ email:
16
+ executables: []
17
+ extensions: []
18
+ extra_rdoc_files: []
19
+ files:
20
+ - CHANGELOG.md
21
+ - LICENSE
22
+ - README.md
23
+ - lib/mailtea.rb
24
+ - lib/mailtea/api_keys.rb
25
+ - lib/mailtea/assets.rb
26
+ - lib/mailtea/automation_runs.rb
27
+ - lib/mailtea/automations.rb
28
+ - lib/mailtea/client.rb
29
+ - lib/mailtea/contact_properties.rb
30
+ - lib/mailtea/contacts.rb
31
+ - lib/mailtea/domains.rb
32
+ - lib/mailtea/emails.rb
33
+ - lib/mailtea/error.rb
34
+ - lib/mailtea/events.rb
35
+ - lib/mailtea/inbound.rb
36
+ - lib/mailtea/posts.rb
37
+ - lib/mailtea/resource.rb
38
+ - lib/mailtea/response.rb
39
+ - lib/mailtea/segments.rb
40
+ - lib/mailtea/senders.rb
41
+ - lib/mailtea/suppressions.rb
42
+ - lib/mailtea/templates.rb
43
+ - lib/mailtea/topics.rb
44
+ - lib/mailtea/transport.rb
45
+ - lib/mailtea/util.rb
46
+ - lib/mailtea/version.rb
47
+ - lib/mailtea/webhook_signing.rb
48
+ - lib/mailtea/webhooks.rb
49
+ homepage: https://mailtea.app
50
+ licenses:
51
+ - MIT
52
+ metadata:
53
+ homepage_uri: https://mailtea.app
54
+ documentation_uri: https://docs.mailtea.app
55
+ source_code_uri: https://github.com/mailtea-app/mailtea-ruby
56
+ changelog_uri: https://github.com/mailtea-app/mailtea-ruby/blob/main/CHANGELOG.md
57
+ bug_tracker_uri: https://github.com/mailtea-app/mailtea-ruby/issues
58
+ rubygems_mfa_required: 'true'
59
+ post_install_message:
60
+ rdoc_options: []
61
+ require_paths:
62
+ - lib
63
+ required_ruby_version: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - ">="
66
+ - !ruby/object:Gem::Version
67
+ version: '3.0'
68
+ required_rubygems_version: !ruby/object:Gem::Requirement
69
+ requirements:
70
+ - - ">="
71
+ - !ruby/object:Gem::Version
72
+ version: '0'
73
+ requirements: []
74
+ rubygems_version: 3.5.22
75
+ signing_key:
76
+ specification_version: 4
77
+ summary: Mailtea Ruby SDK — send, schedule, and manage email from your app or AI agent.
78
+ test_files: []