mailgazelle 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,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "mailgazelle/error"
4
+ require "mailgazelle/http/http_client"
5
+ require "mailgazelle/http/net_http_transport"
6
+ require "mailgazelle/version"
7
+ require "mailgazelle/emails_resource"
8
+ require "mailgazelle/domains_resource"
9
+
10
+ module MailGazelle
11
+ # Entry point for the Mail Gazelle API.
12
+ #
13
+ # Construct one client per API token and reuse it.
14
+ class Client
15
+ DEFAULT_BASE_URL = "https://mailgazelle.com/api/v1"
16
+
17
+ def initialize(api_token:, base_url: nil, transport: nil, timeout: 30, connect_timeout: 10,
18
+ user_agent_suffix: nil)
19
+ token = api_token.to_s.strip
20
+ raise ArgumentError, "A Mail Gazelle API token is required." if token.empty?
21
+
22
+ timeout = Float(timeout)
23
+ connect_timeout = Float(connect_timeout)
24
+ raise ArgumentError, "Timeouts must be greater than zero." if timeout <= 0 || connect_timeout <= 0
25
+
26
+ @http = Http::HttpClient.new(
27
+ transport: transport || Http::NetHttpTransport.new,
28
+ base_url: normalize_base_url(base_url),
29
+ default_headers: {
30
+ "Authorization" => "Bearer #{token}",
31
+ "Accept" => "application/json",
32
+ "User-Agent" => user_agent(user_agent_suffix)
33
+ },
34
+ timeout: timeout,
35
+ connect_timeout: connect_timeout
36
+ )
37
+ end
38
+
39
+ def emails
40
+ @emails ||= EmailsResource.new(@http)
41
+ end
42
+
43
+ def domains
44
+ @domains ||= DomainsResource.new(@http)
45
+ end
46
+
47
+ private
48
+
49
+ def normalize_base_url(base_url)
50
+ normalized = base_url.to_s.strip
51
+ normalized = DEFAULT_BASE_URL if normalized.empty?
52
+ normalized.sub(%r{/+\z}, "")
53
+ end
54
+
55
+ def user_agent(suffix)
56
+ agent = "mailgazelle-ruby/#{VERSION}"
57
+ extra = suffix.to_s.strip
58
+ extra.empty? ? agent : "#{agent} #{extra}"
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MailGazelle
4
+ # Process-wide defaults used by {MailGazelle.client} and ActionMailer.
5
+ class Configuration
6
+ attr_accessor :api_token, :api_base, :timeout, :connect_timeout, :user_agent_suffix
7
+
8
+ def initialize
9
+ @api_token = nil
10
+ @api_base = nil
11
+ @timeout = 30
12
+ @connect_timeout = 10
13
+ @user_agent_suffix = nil
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MailGazelle
4
+ # A product domain and its verification statuses from +GET /domains+.
5
+ #
6
+ # Unknown JSON keys stay on {#to_h}.
7
+ class Domain
8
+ attr_reader :name, :type, :verification_statuses
9
+
10
+ def self.from_h(payload)
11
+ data = string_keyed(payload)
12
+ raw_name = data["name"] || data["domain"] || data["host"] || ""
13
+ raw_type = data["type"] || data["kind"]
14
+
15
+ new(
16
+ name: raw_name.is_a?(String) ? raw_name : "",
17
+ type: raw_type.is_a?(String) && !raw_type.empty? ? raw_type : nil,
18
+ verification_statuses: statuses(data).freeze,
19
+ raw: data
20
+ )
21
+ end
22
+
23
+ def initialize(name:, type:, verification_statuses:, raw:)
24
+ @name = name
25
+ @type = type
26
+ @verification_statuses = verification_statuses
27
+ @raw = raw
28
+ freeze
29
+ end
30
+
31
+ def to_h
32
+ @raw
33
+ end
34
+
35
+ def self.string_keyed(payload)
36
+ return {} unless payload.is_a?(Hash)
37
+
38
+ payload.each_with_object({}) do |(key, value), result|
39
+ result[key.to_s] = value
40
+ end
41
+ end
42
+ private_class_method :string_keyed
43
+
44
+ def self.statuses(data)
45
+ candidates = data["verification"] || data["verification_statuses"] || data["statuses"]
46
+ return direct_statuses(data) unless candidates.is_a?(Hash)
47
+
48
+ candidates.each_with_object({}) do |(key, value), result|
49
+ if value.is_a?(String)
50
+ result[key.to_s] = value
51
+ elsif value.is_a?(Hash) && value["status"].is_a?(String)
52
+ name = value["name"].is_a?(String) ? value["name"] : key.to_s
53
+ result[name] = value["status"]
54
+ end
55
+ end
56
+ end
57
+ private_class_method :statuses
58
+
59
+ def self.direct_statuses(data)
60
+ %w[dkim mail_from spf dmarc].each_with_object({}) do |key, result|
61
+ result[key] = data[key] if data[key].is_a?(String)
62
+ end
63
+ end
64
+ private_class_method :direct_statuses
65
+ end
66
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "mailgazelle/domain"
4
+
5
+ module MailGazelle
6
+ # List the product domains associated with the current API token.
7
+ class DomainsResource
8
+ def initialize(http)
9
+ @http = http
10
+ end
11
+
12
+ def list
13
+ items(@http.request("GET", "/domains")).filter_map do |item|
14
+ Domain.from_h(item) if item.is_a?(Hash)
15
+ end
16
+ end
17
+
18
+ private
19
+
20
+ def items(payload)
21
+ return payload if payload.is_a?(Array)
22
+ return [] unless payload.is_a?(Hash)
23
+
24
+ %w[data domains].each do |key|
25
+ value = payload[key] || payload[key.to_sym]
26
+ return Array(value) if value.is_a?(Array)
27
+ end
28
+
29
+ [payload]
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,291 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "mailgazelle/address"
4
+ require "mailgazelle/attachment"
5
+ require "mailgazelle/error"
6
+
7
+ module MailGazelle
8
+ # Immutable builder for a +POST /emails+ payload.
9
+ #
10
+ # Either HTML or plain text is required. Optional fields are omitted from the
11
+ # JSON body so Mail Gazelle can apply product defaults.
12
+ class Email
13
+ MAX_HTML_BYTES = 512 * 1024
14
+ MAX_ATTACHMENT_BYTES = 7 * 1024 * 1024
15
+ MAX_RECIPIENTS = 50
16
+ MAX_TAGS = 48
17
+ MAX_TAG_NAME_LENGTH = 64
18
+ MAX_TAG_VALUE_LENGTH = 256
19
+ MAX_HEADERS = 50
20
+ MAX_HEADER_VALUE_LENGTH = 8192
21
+ RESERVED_TAGS = %w[team_id product_id].freeze
22
+ TAG_PATTERN = /\A[A-Za-z0-9_-]+\z/
23
+ HEADER_NAME_PATTERN = /\A[A-Za-z0-9-]+\z/
24
+
25
+ def self.to(email, name = nil)
26
+ new(to: [Address.new(email, name)])
27
+ end
28
+
29
+ def add_to(email, name = nil)
30
+ append_recipient(:to, Address.new(email, name))
31
+ end
32
+
33
+ def cc(email, name = nil)
34
+ append_recipient(:cc, Address.new(email, name))
35
+ end
36
+
37
+ def bcc(email, name = nil)
38
+ append_recipient(:bcc, Address.new(email, name))
39
+ end
40
+
41
+ def subject(value)
42
+ copy(subject: value)
43
+ end
44
+
45
+ def text(value)
46
+ copy(text: value)
47
+ end
48
+
49
+ def html(value)
50
+ copy(html: value)
51
+ end
52
+
53
+ def from(email, name = nil)
54
+ copy(from: Address.new(email, name))
55
+ end
56
+
57
+ def reply_to(email, name = nil)
58
+ addresses = @reply_to.dup
59
+ addresses << Address.new(email, name)
60
+ copy(reply_to: addresses.freeze, omit_reply_to: false)
61
+ end
62
+
63
+ def without_reply_to
64
+ copy(reply_to: [].freeze, omit_reply_to: true)
65
+ end
66
+
67
+ def header(name, value)
68
+ self.class.assert_header(name, value)
69
+ headers = @headers.dup
70
+ if !headers.key?(name) && headers.size >= MAX_HEADERS
71
+ raise ValidationError.new(
72
+ "A message can include at most #{MAX_HEADERS} headers.",
73
+ code: "validation_error",
74
+ http_status: 422
75
+ )
76
+ end
77
+
78
+ headers[name] = value
79
+ copy(headers: headers.freeze)
80
+ end
81
+
82
+ def headers(values)
83
+ updated = copy(headers: {}.freeze)
84
+ values.each { |name, value| updated = updated.header(name.to_s, value.to_s) }
85
+ updated
86
+ end
87
+
88
+ def tag(key, value)
89
+ self.class.assert_tag_part(key, MAX_TAG_NAME_LENGTH)
90
+ self.class.assert_tag_part(value, MAX_TAG_VALUE_LENGTH)
91
+ if RESERVED_TAGS.include?(key)
92
+ raise ValidationError.new(
93
+ "The tag names team_id and product_id are reserved.",
94
+ code: "validation_error",
95
+ http_status: 422
96
+ )
97
+ end
98
+
99
+ tags = @tags.dup
100
+ if !tags.key?(key) && tags.size >= MAX_TAGS
101
+ raise ValidationError.new(
102
+ "A message can include at most #{MAX_TAGS} tags.",
103
+ code: "validation_error",
104
+ http_status: 422
105
+ )
106
+ end
107
+
108
+ tags[key] = value
109
+ copy(tags: tags.freeze)
110
+ end
111
+
112
+ def tags(values)
113
+ updated = copy(tags: {}.freeze)
114
+ values.each { |key, value| updated = updated.tag(key.to_s, value.to_s) }
115
+ updated
116
+ end
117
+
118
+ def idempotency_key(key)
119
+ copy(idempotency_key: key)
120
+ end
121
+
122
+ def attach(attachment)
123
+ raise ArgumentError, "Expected a MailGazelle::Attachment" unless attachment.is_a?(Attachment)
124
+
125
+ if attachment.content_id
126
+ @attachments.each do |existing|
127
+ next unless existing.content_id == attachment.content_id
128
+
129
+ raise AttachmentError.new(
130
+ "Attachment content ids must be unique.",
131
+ code: "attachment_invalid",
132
+ http_status: 422
133
+ )
134
+ end
135
+ end
136
+
137
+ attachments = @attachments.dup
138
+ attachments << attachment
139
+ copy(attachments: attachments.freeze)
140
+ end
141
+
142
+ # Validate documented limits and return the JSON body for +POST /emails+.
143
+ def to_payload
144
+ assert_ready
145
+
146
+ payload = {
147
+ to: @to.map(&:to_h),
148
+ subject: @subject
149
+ }
150
+ payload[:html] = @html unless @html.nil?
151
+ payload[:text] = @text unless @text.nil?
152
+ payload[:from] = @from.to_h unless @from.nil?
153
+ payload[:cc] = @cc.map(&:to_h) unless @cc.empty?
154
+ payload[:bcc] = @bcc.map(&:to_h) unless @bcc.empty?
155
+
156
+ if @omit_reply_to
157
+ payload[:reply_to] = []
158
+ elsif !@reply_to.empty?
159
+ payload[:reply_to] = @reply_to.map(&:to_h)
160
+ end
161
+
162
+ payload[:headers] = @headers unless @headers.empty?
163
+ payload[:tags] = @tags unless @tags.empty?
164
+ payload[:idempotency_key] = @idempotency_key if @idempotency_key.is_a?(String) && !@idempotency_key.empty?
165
+ payload[:attachments] = @attachments.map(&:to_h) unless @attachments.empty?
166
+ payload
167
+ end
168
+
169
+ def self.assert_header(name, value)
170
+ unless HEADER_NAME_PATTERN.match?(name)
171
+ raise ValidationError.new(
172
+ "Header names must contain only letters, numbers, and hyphens.",
173
+ code: "validation_error",
174
+ http_status: 422
175
+ )
176
+ end
177
+
178
+ if value.match?(/[\r\n]/)
179
+ raise ValidationError.new(
180
+ "Header values cannot contain line breaks.",
181
+ code: "validation_error",
182
+ http_status: 422
183
+ )
184
+ end
185
+
186
+ return if value.length <= MAX_HEADER_VALUE_LENGTH
187
+
188
+ raise ValidationError.new(
189
+ "Header values must be at most #{MAX_HEADER_VALUE_LENGTH} characters.",
190
+ code: "validation_error",
191
+ http_status: 422
192
+ )
193
+ end
194
+
195
+ def self.assert_tag_part(value, max_length)
196
+ return if TAG_PATTERN.match?(value) && value.length <= max_length
197
+
198
+ raise ValidationError.new(
199
+ "Tag names and values may only contain letters, numbers, underscores, and hyphens.",
200
+ code: "validation_error",
201
+ http_status: 422
202
+ )
203
+ end
204
+
205
+ def initialize(to:, subject: nil, text: nil, html: nil, from: nil, cc: [].freeze, bcc: [].freeze,
206
+ reply_to: [].freeze, omit_reply_to: false, headers: {}.freeze, tags: {}.freeze,
207
+ idempotency_key: nil, attachments: [].freeze)
208
+ @to = to.freeze
209
+ @subject = subject
210
+ @text = text
211
+ @html = html
212
+ @from = from
213
+ @cc = cc.freeze
214
+ @bcc = bcc.freeze
215
+ @reply_to = reply_to.freeze
216
+ @omit_reply_to = omit_reply_to
217
+ @headers = headers.freeze
218
+ @tags = tags.freeze
219
+ @idempotency_key = idempotency_key
220
+ @attachments = attachments.freeze
221
+ freeze
222
+ end
223
+
224
+ private
225
+
226
+ def copy(**changes)
227
+ self.class.new(
228
+ to: changes.fetch(:to, @to),
229
+ subject: changes.fetch(:subject, @subject),
230
+ text: changes.fetch(:text, @text),
231
+ html: changes.fetch(:html, @html),
232
+ from: changes.fetch(:from, @from),
233
+ cc: changes.fetch(:cc, @cc),
234
+ bcc: changes.fetch(:bcc, @bcc),
235
+ reply_to: changes.fetch(:reply_to, @reply_to),
236
+ omit_reply_to: changes.fetch(:omit_reply_to, @omit_reply_to),
237
+ headers: changes.fetch(:headers, @headers),
238
+ tags: changes.fetch(:tags, @tags),
239
+ idempotency_key: changes.fetch(:idempotency_key, @idempotency_key),
240
+ attachments: changes.fetch(:attachments, @attachments)
241
+ )
242
+ end
243
+
244
+ def append_recipient(field, address)
245
+ if @to.size + @cc.size + @bcc.size >= MAX_RECIPIENTS
246
+ raise ValidationError.new(
247
+ "A message can include at most #{MAX_RECIPIENTS} recipients.",
248
+ code: "validation_error",
249
+ http_status: 422
250
+ )
251
+ end
252
+
253
+ list = instance_variable_get(:"@#{field}").dup
254
+ list << address
255
+ copy(field => list.freeze)
256
+ end
257
+
258
+ def assert_ready
259
+ if @subject.nil? || @subject.strip.empty?
260
+ raise ValidationError.new("A subject is required.", code: "validation_error", http_status: 422)
261
+ end
262
+
263
+ has_html = !@html.nil? && !@html.empty?
264
+ has_text = !@text.nil? && !@text.empty?
265
+ unless has_html || has_text
266
+ raise ValidationError.new(
267
+ "Either html or text is required.",
268
+ code: "validation_error",
269
+ http_status: 422
270
+ )
271
+ end
272
+
273
+ if has_html && @html.bytesize > MAX_HTML_BYTES
274
+ raise HtmlTooLargeError.new(
275
+ "HTML must be at most #{MAX_HTML_BYTES} bytes.",
276
+ code: "html_too_large",
277
+ http_status: 422
278
+ )
279
+ end
280
+
281
+ total = @attachments.sum(&:decoded_size)
282
+ return if total <= MAX_ATTACHMENT_BYTES
283
+
284
+ raise AttachmentTooLargeError.new(
285
+ "Decoded attachments must total at most #{MAX_ATTACHMENT_BYTES} bytes.",
286
+ code: "attachment_too_large",
287
+ http_status: 422
288
+ )
289
+ end
290
+ end
291
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MailGazelle
4
+ # Attachment metadata returned by +GET /emails/{id}+.
5
+ #
6
+ # File bytes are never included.
7
+ class AttachmentMeta
8
+ attr_reader :filename, :content_type, :size
9
+
10
+ def self.from_h(payload)
11
+ data = string_keyed(payload)
12
+ new(
13
+ filename: data["filename"].is_a?(String) ? data["filename"] : "",
14
+ content_type: data["content_type"].is_a?(String) ? data["content_type"] : nil,
15
+ size: integer_or_nil(data["size"]),
16
+ raw: data
17
+ )
18
+ end
19
+
20
+ def initialize(filename:, content_type:, size:, raw:)
21
+ @filename = filename
22
+ @content_type = content_type
23
+ @size = size
24
+ @raw = raw
25
+ freeze
26
+ end
27
+
28
+ def to_h
29
+ @raw
30
+ end
31
+
32
+ def self.string_keyed(payload)
33
+ return {} unless payload.is_a?(Hash)
34
+
35
+ payload.each_with_object({}) do |(key, value), result|
36
+ result[key.to_s] = value
37
+ end
38
+ end
39
+ private_class_method :string_keyed
40
+
41
+ def self.integer_or_nil(value)
42
+ return value if value.is_a?(Integer)
43
+ return Integer(value) if value.is_a?(String) && value.match?(/\A-?\d+\z/)
44
+ return value.to_i if value.is_a?(Numeric)
45
+
46
+ nil
47
+ end
48
+ private_class_method :integer_or_nil
49
+ end
50
+ end
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+ require "mailgazelle/emails/attachment_meta"
5
+ require "mailgazelle/emails/email_status"
6
+
7
+ module MailGazelle
8
+ # Status record returned by +GET /emails/{id}+.
9
+ #
10
+ # HTML, text, and attachment bytes are not included. Unknown JSON keys stay
11
+ # on {#to_h} so later API fields do not break older clients.
12
+ class EmailRecord
13
+ attr_reader :id, :status, :message_id, :last_event_type, :created_at, :updated_at, :sent_at, :attachments
14
+
15
+ def self.from_h(payload)
16
+ data = unwrap(payload)
17
+ attachments = Array(data["attachments"]).filter_map do |item|
18
+ AttachmentMeta.from_h(item) if item.is_a?(Hash)
19
+ end
20
+
21
+ new(
22
+ id: string_value(data, "id") || "",
23
+ status: string_value(data, "status") || "",
24
+ message_id: string_value(data, "message_id") ||
25
+ string_value(data, "provider_message_id") ||
26
+ string_value(data, "ses_message_id"),
27
+ last_event_type: string_value(data, "last_event_type") || string_value(data, "last_event"),
28
+ created_at: timestamp(data, "created_at"),
29
+ updated_at: timestamp(data, "updated_at"),
30
+ sent_at: timestamp(data, "sent_at"),
31
+ attachments: attachments.freeze,
32
+ raw: data
33
+ )
34
+ end
35
+
36
+ def initialize(id:, status:, message_id:, last_event_type:, created_at:, updated_at:, sent_at:, attachments:, raw:)
37
+ @id = id
38
+ @status = status
39
+ @message_id = message_id
40
+ @last_event_type = last_event_type
41
+ @created_at = created_at
42
+ @updated_at = updated_at
43
+ @sent_at = sent_at
44
+ @attachments = attachments
45
+ @raw = raw
46
+ freeze
47
+ end
48
+
49
+ # The documented status when this SDK version knows it, otherwise +nil+.
50
+ def known_status
51
+ EmailStatus.known?(status) ? status : nil
52
+ end
53
+
54
+ def to_h
55
+ @raw
56
+ end
57
+
58
+ def self.unwrap(payload)
59
+ data = string_keyed(payload)
60
+ nested = data["data"]
61
+ data = string_keyed(nested) if nested.is_a?(Hash)
62
+ data
63
+ end
64
+ private_class_method :unwrap
65
+
66
+ def self.string_keyed(payload)
67
+ return {} unless payload.is_a?(Hash)
68
+
69
+ payload.each_with_object({}) do |(key, value), result|
70
+ result[key.to_s] = value
71
+ end
72
+ end
73
+ private_class_method :string_keyed
74
+
75
+ def self.string_value(data, key)
76
+ value = data[key]
77
+ value.is_a?(String) && !value.empty? ? value : nil
78
+ end
79
+ private_class_method :string_value
80
+
81
+ def self.timestamp(data, key)
82
+ value = data[key]
83
+ return nil unless value.is_a?(String) && !value.empty?
84
+
85
+ Time.iso8601(value)
86
+ rescue ArgumentError
87
+ nil
88
+ end
89
+ private_class_method :timestamp
90
+ end
91
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MailGazelle
4
+ # Message statuses documented by the send path.
5
+ #
6
+ # Additional statuses may appear on {EmailRecord#status} as raw strings when
7
+ # the API adds new values.
8
+ module EmailStatus
9
+ QUEUED = "queued"
10
+ REJECTED = "rejected"
11
+ KNOWN = [QUEUED, REJECTED].freeze
12
+
13
+ module_function
14
+
15
+ def known?(value)
16
+ KNOWN.include?(value)
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "mailgazelle/emails/email_status"
4
+
5
+ module MailGazelle
6
+ # Immediate 202 result of +POST /emails+.
7
+ #
8
+ # The message is persisted and queued. Delivery happens asynchronously.
9
+ class QueuedEmail
10
+ attr_reader :id, :status
11
+
12
+ def self.from_h(payload)
13
+ data = string_keyed(payload)
14
+ new(
15
+ id: data["id"].is_a?(String) ? data["id"] : "",
16
+ status: data["status"].is_a?(String) ? data["status"] : EmailStatus::QUEUED,
17
+ raw: data
18
+ )
19
+ end
20
+
21
+ def initialize(id:, status:, raw:)
22
+ @id = id
23
+ @status = status
24
+ @raw = raw
25
+ freeze
26
+ end
27
+
28
+ def to_h
29
+ @raw
30
+ end
31
+
32
+ def self.string_keyed(payload)
33
+ return {} unless payload.is_a?(Hash)
34
+
35
+ payload.each_with_object({}) do |(key, value), result|
36
+ result[key.to_s] = value
37
+ end
38
+ end
39
+ private_class_method :string_keyed
40
+ end
41
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+ require "mailgazelle/email"
5
+ require "mailgazelle/emails/email_record"
6
+ require "mailgazelle/emails/queued_email"
7
+ require "mailgazelle/error"
8
+
9
+ module MailGazelle
10
+ # Send and inspect transactional emails.
11
+ class EmailsResource
12
+ def initialize(http)
13
+ @http = http
14
+ end
15
+
16
+ # Persist a message and queue a send. Returns immediately on HTTP 202.
17
+ #
18
+ # The same idempotency key returns the original message and does not send again.
19
+ def send(email)
20
+ raise ArgumentError, "Expected a MailGazelle::Email" unless email.is_a?(Email)
21
+
22
+ QueuedEmail.from_h(@http.request("POST", "/emails", json: email.to_payload))
23
+ end
24
+
25
+ # Fetch status, timestamps, provider message id, last event, and attachment metadata.
26
+ def get(id)
27
+ trimmed = id.to_s.strip
28
+ if trimmed.empty?
29
+ raise ValidationError.new("An email id is required.", code: "validation_error", http_status: 422)
30
+ end
31
+
32
+ EmailRecord.from_h(@http.request("GET", "/emails/#{URI.encode_uri_component(trimmed)}"))
33
+ end
34
+ end
35
+ end