relaygrid 0.1.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,171 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "faraday"
4
+ require "faraday/retry"
5
+ require "json"
6
+
7
+ module RelayGrid
8
+ # The entry point for every API call. Holds one Faraday connection and hands
9
+ # out the resource objects.
10
+ #
11
+ # client = RelayGrid::Client.new # global configuration
12
+ # client = RelayGrid::Client.new(api_key: "sk_other") # per-client override
13
+ #
14
+ # Instances are frozen once built, so a single client is safe to share across
15
+ # threads.
16
+ class Client
17
+ # Requests that may be replayed without changing server state. `notify` is
18
+ # deliberately absent: a blind retry sends the recipient a second real
19
+ # notification. See README ("Retries and idempotency").
20
+ IDEMPOTENT_METHODS = %i[get head options].freeze
21
+
22
+ RETRIABLE_STATUSES = [429, 500, 502, 503, 504].freeze
23
+
24
+ RETRIABLE_EXCEPTIONS = [
25
+ Faraday::ConnectionFailed,
26
+ Faraday::TimeoutError,
27
+ # Required for RETRIABLE_STATUSES to have any effect: faraday-retry signals
28
+ # a retriable status by raising this, and only retries exceptions listed
29
+ # here. Leaving it out silently disables status-based retries.
30
+ Faraday::RetriableResponse,
31
+ ].freeze
32
+
33
+ attr_reader :configuration
34
+
35
+ def initialize(configuration = nil, **overrides)
36
+ base = configuration || RelayGrid.configuration
37
+ @configuration = base.merge(overrides).validate!
38
+
39
+ # Built up front so the frozen instance never has to memoize lazily.
40
+ @connection = build_connection
41
+ @resources = {}
42
+
43
+ freeze
44
+ end
45
+
46
+ def notifications
47
+ resource(:notifications) { Resources::Notifications.new(self) }
48
+ end
49
+
50
+ def deliveries
51
+ resource(:deliveries) { Resources::Deliveries.new(self) }
52
+ end
53
+
54
+ def messages
55
+ resource(:messages) { Resources::Messages.new(self) }
56
+ end
57
+
58
+ def users
59
+ resource(:users) { Resources::Users.new(self) }
60
+ end
61
+
62
+ def channel_tokens
63
+ resource(:channel_tokens) { Resources::ChannelTokens.new(self) }
64
+ end
65
+
66
+ def message_templates
67
+ resource(:message_templates) { Resources::MessageTemplates.new(self) }
68
+ end
69
+
70
+ # Send a notification -- the one call the whole gem exists for.
71
+ # See Resources::Notifications#notify.
72
+ def notify(**kwargs)
73
+ notifications.notify(**kwargs)
74
+ end
75
+
76
+ def get(path, params: {})
77
+ request(:get, path, params: params)
78
+ end
79
+
80
+ def post(path, body: {})
81
+ request(:post, path, body: body)
82
+ end
83
+
84
+ def patch(path, body: {})
85
+ request(:patch, path, body: body)
86
+ end
87
+
88
+ def delete(path)
89
+ request(:delete, path)
90
+ end
91
+
92
+ attr_reader :connection
93
+
94
+ private
95
+
96
+ # Resource objects are stateless wrappers, so building one twice under a
97
+ # race is harmless -- which is what lets the client stay frozen.
98
+ def resource(name)
99
+ @resources[name] ||= yield
100
+ end
101
+
102
+ def build_connection
103
+ Faraday.new(url: configuration.base_url) do |conn|
104
+ conn.request :json
105
+ # Only idempotent verbs are replayed, so this can never duplicate a send.
106
+ conn.request :retry,
107
+ max: configuration.max_retries,
108
+ interval: 0.25,
109
+ backoff_factor: 2,
110
+ interval_randomness: 0.5,
111
+ methods: IDEMPOTENT_METHODS,
112
+ retry_statuses: RETRIABLE_STATUSES,
113
+ exceptions: RETRIABLE_EXCEPTIONS
114
+ conn.response :json, content_type: /\bjson$/
115
+ conn.response :logger, configuration.logger, headers: false, bodies: false if configuration.logger
116
+
117
+ conn.headers["Authorization"] = "Bearer #{configuration.api_key}"
118
+ conn.headers["User-Agent"] = "relaygrid-ruby/#{RelayGrid::VERSION}"
119
+
120
+ conn.options.timeout = configuration.timeout
121
+ conn.options.open_timeout = configuration.open_timeout
122
+ # Certificates are always verified; ca_file only adds a trust anchor.
123
+ conn.ssl.ca_file = configuration.ca_file if configuration.ca_file
124
+
125
+ conn.adapter Faraday.default_adapter
126
+ end
127
+ end
128
+
129
+ # Single funnel for every call: the one place that turns transport failures
130
+ # and non-2xx responses into the RelayGrid::Error hierarchy, so no Faraday
131
+ # exception ever escapes the gem.
132
+ def request(method, path, params: nil, body: nil)
133
+ response =
134
+ case method
135
+ when :get then connection.get(path, params || {})
136
+ when :post then connection.post(path, body || {})
137
+ when :patch then connection.patch(path, body || {})
138
+ when :delete then connection.delete(path)
139
+ else raise ArgumentError, "unsupported HTTP method: #{method}"
140
+ end
141
+
142
+ return response.body if response.success?
143
+
144
+ raise APIError.from_response(response.status, response.body)
145
+ rescue Faraday::RetriableResponse => e
146
+ # faraday-retry raises this instead of returning the response when the
147
+ # status is retriable but no attempts are left -- and immediately, without
148
+ # retrying, for a non-idempotent method like the POST behind `notify`.
149
+ # Convert it like any other unsuccessful response.
150
+ raise APIError.from_response(*retriable_response_parts(e))
151
+ rescue Faraday::TimeoutError => e
152
+ raise TimeoutError, "Request to #{path} timed out: #{e.message}"
153
+ rescue Faraday::SSLError => e
154
+ raise ConnectionError, "TLS handshake with #{configuration.base_url} failed: #{e.message}"
155
+ rescue Faraday::ConnectionFailed => e
156
+ raise ConnectionError, "Could not connect to #{configuration.base_url}: #{e.message}"
157
+ end
158
+
159
+ # Faraday::RetriableResponse carries either a Faraday::Response or a raw env
160
+ # hash depending on where it was raised.
161
+ def retriable_response_parts(error)
162
+ response = error.response
163
+
164
+ if response.respond_to?(:status)
165
+ [response.status, response.body]
166
+ else
167
+ [response[:status], response[:body]]
168
+ end
169
+ end
170
+ end
171
+ end
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+
5
+ module RelayGrid
6
+ # Settings a Client reads. `RelayGrid.configure` sets the process-wide
7
+ # default; `Client.new(api_key: ...)` takes per-client overrides so a
8
+ # multi-account host app can talk to several accounts at once.
9
+ class Configuration
10
+ DEFAULT_BASE_URL = "https://relaygrid.dev"
11
+
12
+ # @return [String] account API key ("sk_...")
13
+ attr_accessor :api_key
14
+ # @return [String] API origin, e.g. "https://relaygrid.dev"
15
+ attr_accessor :base_url
16
+ # @return [Integer] seconds to wait for a response
17
+ attr_accessor :timeout
18
+ # @return [Integer] seconds to wait for the connection to open
19
+ attr_accessor :open_timeout
20
+ # @return [Logger, nil] when set, requests are logged (headers redacted)
21
+ attr_accessor :logger
22
+ # @return [String, nil] PEM bundle for a private CA. TLS certificates are
23
+ # always verified; this only adds trust anchors (self-signed local dev,
24
+ # internal deployments). There is deliberately no switch to turn
25
+ # verification off -- this client carries an API key and relays push
26
+ # channel tokens, so an unverified connection is never the right default.
27
+ attr_accessor :ca_file
28
+ # @return [String, nil] overrides the Action Cable URL derived from base_url
29
+ attr_accessor :ws_url
30
+ # @return [Integer] retry attempts for idempotent (GET) requests
31
+ attr_accessor :max_retries
32
+
33
+ def initialize(api_key: nil, base_url: nil, timeout: 30, open_timeout: 10, logger: nil,
34
+ ca_file: nil, ws_url: nil, max_retries: 3)
35
+ @api_key = api_key
36
+ @base_url = base_url || DEFAULT_BASE_URL
37
+ @timeout = timeout
38
+ @open_timeout = open_timeout
39
+ @logger = logger
40
+ @ca_file = ca_file
41
+ @ws_url = ws_url
42
+ @max_retries = max_retries
43
+ end
44
+
45
+ # A copy with the given attributes replaced. Client.new layers per-client
46
+ # overrides over the global default this way, without mutating it.
47
+ def merge(overrides = {})
48
+ dup.tap do |copy|
49
+ overrides.each do |attribute, value|
50
+ copy.public_send(:"#{attribute}=", value) unless value.nil?
51
+ end
52
+ end
53
+ end
54
+
55
+ # Fail at construction rather than on the first request, so a misconfigured
56
+ # deploy surfaces at boot.
57
+ def validate!
58
+ raise ConfigurationError, "api_key is required" if blank?(api_key)
59
+ raise ConfigurationError, "base_url is required" if blank?(base_url)
60
+
61
+ uri = begin
62
+ URI.parse(base_url.to_s)
63
+ rescue URI::InvalidURIError
64
+ nil
65
+ end
66
+
67
+ unless uri.is_a?(URI::HTTP) && !uri.host.to_s.empty?
68
+ raise ConfigurationError, "base_url must be an http(s) URL, got #{base_url.inspect}"
69
+ end
70
+
71
+ if ca_file && !File.readable?(ca_file.to_s)
72
+ raise ConfigurationError, "ca_file is not readable: #{ca_file.inspect}"
73
+ end
74
+
75
+ self
76
+ end
77
+
78
+ # Action Cable endpoint, derived from base_url unless one was set explicitly
79
+ # ("https://relaygrid.dev" -> "wss://relaygrid.dev/cable").
80
+ def cable_url
81
+ return ws_url if ws_url
82
+
83
+ uri = URI.parse(base_url.to_s)
84
+ uri.scheme = uri.scheme == "https" ? "wss" : "ws"
85
+ uri.path = "/cable"
86
+ uri.query = nil
87
+ uri.to_s
88
+ end
89
+
90
+ private
91
+
92
+ def blank?(value)
93
+ value.nil? || value.to_s.strip.empty?
94
+ end
95
+ end
96
+ end
@@ -0,0 +1,129 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module RelayGrid
6
+ # One attempt to deliver a message over one channel (email, push, ...).
7
+ #
8
+ # Immutable: `#refresh` returns a new Delivery rather than mutating this one,
9
+ # so a Delivery handed to another thread never changes underneath it.
10
+ class Delivery
11
+ QUEUED = "queued"
12
+ SENT = "sent"
13
+ DELIVERED = "delivered"
14
+ FAILED = "failed"
15
+ BOUNCED = "bounced"
16
+ OPENED = "opened"
17
+
18
+ # States the server will not move away from on its own.
19
+ SUCCESS_STATUSES = [DELIVERED, OPENED].freeze
20
+
21
+ attr_reader :id, :message_id, :status, :channel_id, :channel_name, :channel_type,
22
+ :error_message, :friendly_error_message,
23
+ :sent_at, :delivered_at, :failed_at, :opened_at, :created_at, :updated_at,
24
+ :raw
25
+
26
+ def initialize(attributes, client: nil)
27
+ @raw = attributes
28
+ @client = client
29
+
30
+ @id = attributes["id"]
31
+ @message_id = attributes["message_id"]
32
+ @status = attributes["status"]
33
+ @error_message = attributes["error_message"]
34
+ @friendly_error_message = attributes["friendly_error_message"]
35
+
36
+ channel = attributes["notification_channel"] || {}
37
+ @channel_id = channel["id"]
38
+ @channel_name = channel["name"]
39
+ @channel_type = channel["channel_type"]
40
+
41
+ @sent_at = parse_time(attributes["sent_at"])
42
+ @delivered_at = parse_time(attributes["delivered_at"])
43
+ @failed_at = parse_time(attributes["failed_at"])
44
+ @opened_at = parse_time(attributes["opened_at"])
45
+ @created_at = parse_time(attributes["created_at"])
46
+ @updated_at = parse_time(attributes["updated_at"])
47
+
48
+ freeze
49
+ end
50
+
51
+ def queued?
52
+ status == QUEUED
53
+ end
54
+
55
+ def sent?
56
+ status == SENT
57
+ end
58
+
59
+ def delivered?
60
+ status == DELIVERED
61
+ end
62
+
63
+ def failed?
64
+ status == FAILED
65
+ end
66
+
67
+ def bounced?
68
+ status == BOUNCED
69
+ end
70
+
71
+ def opened?
72
+ status == OPENED
73
+ end
74
+
75
+ # Reached a state the server considers final and successful.
76
+ #
77
+ # `failed` is deliberately *not* terminal: delivery jobs retry, so a failed
78
+ # row can still flip to `sent`. This mirrors the dashboard's polling
79
+ # semantics (see docs/DELIVERY_STATUS_PLAN.md in the API repo).
80
+ def success?
81
+ SUCCESS_STATUSES.include?(status)
82
+ end
83
+
84
+ def push?
85
+ channel_type == "push"
86
+ end
87
+
88
+ def email?
89
+ channel_type == "email"
90
+ end
91
+
92
+ # @return [RelayGrid::Delivery] this delivery's current server-side state
93
+ def refresh
94
+ client.deliveries.get(id)
95
+ end
96
+
97
+ def to_h
98
+ raw
99
+ end
100
+
101
+ def ==(other)
102
+ other.is_a?(Delivery) && other.id == id && other.status == status
103
+ end
104
+ alias eql? ==
105
+
106
+ def hash
107
+ [self.class, id, status].hash
108
+ end
109
+
110
+ def inspect
111
+ "#<RelayGrid::Delivery id: #{id.inspect}, channel_type: #{channel_type.inspect}, " \
112
+ "status: #{status.inspect}>"
113
+ end
114
+
115
+ private
116
+
117
+ def client
118
+ @client || raise(Error, "This Delivery was built without a client and cannot be refreshed")
119
+ end
120
+
121
+ def parse_time(value)
122
+ return nil if value.nil? || value.to_s.empty?
123
+
124
+ Time.parse(value.to_s)
125
+ rescue ArgumentError
126
+ nil
127
+ end
128
+ end
129
+ end
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RelayGrid
4
+ # Every error the gem raises descends from this, so `rescue RelayGrid::Error`
5
+ # is always enough. Faraday's own exceptions are never allowed to escape.
6
+ class Error < StandardError; end
7
+
8
+ # Bad or missing client configuration; raised at construction, not on request.
9
+ class ConfigurationError < Error; end
10
+
11
+ # Transport-level failure -- the request never produced an HTTP response.
12
+ class ConnectionError < Error; end
13
+
14
+ # A ConnectionError specifically from a timeout. Retryable.
15
+ class TimeoutError < ConnectionError; end
16
+
17
+ # The server answered, but not with success.
18
+ class APIError < Error
19
+ # @return [Integer, nil] HTTP status
20
+ attr_reader :status
21
+ # @return [Hash, String, nil] parsed response body
22
+ attr_reader :body
23
+
24
+ def initialize(message = nil, status: nil, body: nil)
25
+ @status = status
26
+ @body = body
27
+ super(message || default_message)
28
+ end
29
+
30
+ # The server's own `error` string when it sent one.
31
+ def error_message
32
+ body["error"] if body.is_a?(Hash)
33
+ end
34
+
35
+ # Maps an HTTP response onto the right subclass. 404s are discriminated
36
+ # further by the server's message so callers can tell "no such template"
37
+ # from "no such user" without string-matching themselves.
38
+ def self.from_response(status, body)
39
+ klass =
40
+ case status
41
+ when 401, 403 then AuthenticationError
42
+ when 402 then LimitExceededError
43
+ when 404 then not_found_class(body)
44
+ when 422 then ValidationError
45
+ when 400..499 then APIError
46
+ when 500..599 then ServerError
47
+ else APIError
48
+ end
49
+
50
+ klass.new(status: status, body: body)
51
+ end
52
+
53
+ def self.not_found_class(body)
54
+ error = body.is_a?(Hash) ? body["error"].to_s : body.to_s
55
+
56
+ case error
57
+ when /message template/i then TemplateNotFoundError
58
+ when /notification user/i then UserNotFoundError
59
+ else NotFoundError
60
+ end
61
+ end
62
+ private_class_method :not_found_class
63
+
64
+ private
65
+
66
+ def default_message
67
+ detail = error_message || body
68
+ "RelayGrid API error (HTTP #{status})#{": #{detail}" unless detail.nil?}"
69
+ end
70
+ end
71
+
72
+ # 401/403 -- the API key is missing, invalid, inactive, expired, or its
73
+ # account is suspended.
74
+ class AuthenticationError < APIError; end
75
+
76
+ # 402 -- the account's monthly notification limit is exhausted.
77
+ class LimitExceededError < APIError; end
78
+
79
+ # 404 -- the referenced record does not exist within this account.
80
+ class NotFoundError < APIError; end
81
+
82
+ # 404 naming a message template.
83
+ class TemplateNotFoundError < NotFoundError; end
84
+
85
+ # 404 naming a notification user.
86
+ class UserNotFoundError < NotFoundError; end
87
+
88
+ # 422 -- the payload reached the server but was rejected.
89
+ class ValidationError < APIError; end
90
+
91
+ # 5xx -- a fault on the RelayGrid side. Safe to retry for reads.
92
+ class ServerError < APIError; end
93
+
94
+ # `wait_for_deliveries` gave up before every delivery reached a terminal
95
+ # state. Carries the deliveries as last seen so the caller can inspect them.
96
+ class TimeoutWaitingForDeliveries < Error
97
+ # @return [Array<RelayGrid::Delivery>]
98
+ attr_reader :deliveries
99
+
100
+ def initialize(message = nil, deliveries: [])
101
+ @deliveries = deliveries
102
+ super(message || "Timed out waiting for deliveries to settle: #{deliveries.map(&:id).join(', ')}")
103
+ end
104
+ end
105
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RelayGrid
4
+ module Resources
5
+ # Mints the short-lived token an end user's device presents when subscribing
6
+ # to the realtime channel.
7
+ #
8
+ # A send already returns one (`SendResult#push_token`); this is how to get a
9
+ # fresh one once the hour is up, or without sending anything.
10
+ class ChannelTokens
11
+ def initialize(client)
12
+ @client = client
13
+ end
14
+
15
+ # @param user_id [String] your identifier for the user (external_user_id)
16
+ # @return [Hash] `{ token:, expires_in:, expires_at: }`
17
+ # @raise [RelayGrid::UserNotFoundError] no such recipient in this account
18
+ def create(user_id:)
19
+ body = @client.post("/api/v1/channel_tokens", body: { external_user_id: user_id.to_s })
20
+ expires_in = body["expires_in"]
21
+
22
+ {
23
+ token: body["token"],
24
+ expires_in: expires_in,
25
+ expires_at: expires_in ? Time.now + expires_in.to_i : nil,
26
+ }
27
+ end
28
+
29
+ # @return [String] just the token
30
+ def token_for(user_id)
31
+ create(user_id: user_id)[:token]
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RelayGrid
4
+ module Resources
5
+ # Delivery status lookup for the ids a send returned.
6
+ class Deliveries
7
+ # Matches the server's cap (Api::V1::DeliveriesController::MAX_BATCH_SIZE);
8
+ # longer id lists are split across several requests.
9
+ MAX_BATCH_SIZE = 100
10
+
11
+ def initialize(client)
12
+ @client = client
13
+ end
14
+
15
+ # @param id [Integer, String]
16
+ # @return [RelayGrid::Delivery]
17
+ # @raise [RelayGrid::NotFoundError] no such delivery in this account
18
+ def get(id)
19
+ body = @client.get("/api/v1/deliveries/#{id}")
20
+ Delivery.new(body["delivery"], client: @client)
21
+ end
22
+
23
+ # Batch lookup. Ids that don't resolve are simply absent from the result,
24
+ # so one stale id never fails a whole poll.
25
+ #
26
+ # @param ids [Array<Integer>]
27
+ # @return [Array<RelayGrid::Delivery>]
28
+ def get_all(ids)
29
+ ids = Array(ids).compact
30
+ return [] if ids.empty?
31
+
32
+ ids.each_slice(MAX_BATCH_SIZE).flat_map do |batch|
33
+ body = @client.get("/api/v1/deliveries", params: { ids: batch.join(",") })
34
+ Array(body["deliveries"]).map { |delivery| Delivery.new(delivery, client: @client) }
35
+ end
36
+ end
37
+
38
+ # Poll until every delivery reaches a success state or `timeout` elapses.
39
+ #
40
+ # BLOCKING. This sleeps the calling thread between polls. That is fine in a
41
+ # background job, a rake task, a script, or a test -- but do not call it
42
+ # from inside a web request: it pins a request thread for up to `timeout`
43
+ # seconds, and enough concurrent sends will exhaust the pool. In a request,
44
+ # return the delivery ids and let the browser poll `#get_all`, or subscribe
45
+ # to the realtime channel with `RelayGrid.websocket`.
46
+ #
47
+ # `failed` is not terminal here: delivery jobs retry, so a failed row can
48
+ # still flip to `sent` and then `delivered` inside the window. Waiting out
49
+ # the timeout on a genuinely failed delivery is the deliberate trade -- it
50
+ # mirrors the dashboard, and reporting a transient failure as final is the
51
+ # worse error.
52
+ #
53
+ # @param ids [Array<Integer>]
54
+ # @param timeout [Numeric] seconds
55
+ # @param interval [Numeric] seconds between polls
56
+ # @param raise_on_timeout [Boolean]
57
+ # @return [Array<RelayGrid::Delivery>] as last seen
58
+ # @raise [RelayGrid::TimeoutWaitingForDeliveries] when `raise_on_timeout`
59
+ def wait_for(ids, timeout: 30, interval: 2, raise_on_timeout: false)
60
+ ids = Array(ids).compact
61
+ return [] if ids.empty?
62
+
63
+ deadline = monotonic_now + timeout
64
+ deliveries = []
65
+
66
+ loop do
67
+ deliveries = get_all(ids)
68
+ return deliveries if deliveries.any? && deliveries.all?(&:success?)
69
+
70
+ # Never sleep past the deadline: the caller's timeout is honoured to
71
+ # within one request, not rounded up to the next whole interval.
72
+ remaining = deadline - monotonic_now
73
+ break if remaining <= 0
74
+
75
+ sleeper.call([interval, remaining].min)
76
+ end
77
+
78
+ raise TimeoutWaitingForDeliveries.new(deliveries: deliveries) if raise_on_timeout
79
+
80
+ deliveries
81
+ end
82
+
83
+ private
84
+
85
+ # `sleeper` and `clock` are separate seams so specs can drive the loop
86
+ # with a fake clock -- the fake sleeper advances the fake clock -- instead
87
+ # of sleeping for real.
88
+ def sleeper
89
+ @sleeper ||= ->(seconds) { sleep(seconds) }
90
+ end
91
+
92
+ def clock
93
+ @clock ||= -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }
94
+ end
95
+
96
+ def monotonic_now
97
+ clock.call
98
+ end
99
+ end
100
+ end
101
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RelayGrid
4
+ module Resources
5
+ # Read access to the account's templates. `notify` takes a template *name*,
6
+ # so this is mostly for discovery -- listing what's available, or checking
7
+ # which attributes a template expects before sending.
8
+ class MessageTemplates
9
+ def initialize(client)
10
+ @client = client
11
+ end
12
+
13
+ # @return [Array<Hash>] every template in the account
14
+ def list
15
+ Array(@client.get("/api/v1/message_templates")["message_templates"])
16
+ end
17
+
18
+ # @param id [Integer]
19
+ # @return [Hash]
20
+ def get(id)
21
+ @client.get("/api/v1/message_templates/#{id}")["message_template"]
22
+ end
23
+
24
+ # Look a template up by the same name `notify` takes.
25
+ #
26
+ # @param name [String]
27
+ # @return [Hash, nil]
28
+ def find_by_name(name)
29
+ list.find { |template| template["name"] == name.to_s }
30
+ end
31
+ end
32
+ end
33
+ end