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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +67 -0
- data/LICENSE +21 -0
- data/README.md +234 -0
- data/lib/mailtea/api_keys.rb +29 -0
- data/lib/mailtea/assets.rb +69 -0
- data/lib/mailtea/automation_runs.rb +48 -0
- data/lib/mailtea/automations.rb +157 -0
- data/lib/mailtea/client.rb +183 -0
- data/lib/mailtea/contact_properties.rb +30 -0
- data/lib/mailtea/contacts.rb +55 -0
- data/lib/mailtea/domains.rb +151 -0
- data/lib/mailtea/emails.rb +135 -0
- data/lib/mailtea/error.rb +44 -0
- data/lib/mailtea/events.rb +86 -0
- data/lib/mailtea/inbound.rb +66 -0
- data/lib/mailtea/posts.rb +77 -0
- data/lib/mailtea/resource.rb +34 -0
- data/lib/mailtea/response.rb +62 -0
- data/lib/mailtea/segments.rb +40 -0
- data/lib/mailtea/senders.rb +40 -0
- data/lib/mailtea/suppressions.rb +37 -0
- data/lib/mailtea/templates.rb +121 -0
- data/lib/mailtea/topics.rb +49 -0
- data/lib/mailtea/transport.rb +116 -0
- data/lib/mailtea/util.rb +77 -0
- data/lib/mailtea/version.rb +5 -0
- data/lib/mailtea/webhook_signing.rb +113 -0
- data/lib/mailtea/webhooks.rb +42 -0
- data/lib/mailtea.rb +25 -0
- metadata +78 -0
data/lib/mailtea/util.rb
ADDED
|
@@ -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,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: []
|