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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +6 -0
- data/LICENSE +21 -0
- data/README.md +265 -0
- data/docs/api.md +183 -0
- data/lib/mailgazelle/address.rb +33 -0
- data/lib/mailgazelle/attachment.rb +158 -0
- data/lib/mailgazelle/client.rb +61 -0
- data/lib/mailgazelle/configuration.rb +16 -0
- data/lib/mailgazelle/domain.rb +66 -0
- data/lib/mailgazelle/domains_resource.rb +32 -0
- data/lib/mailgazelle/email.rb +291 -0
- data/lib/mailgazelle/emails/attachment_meta.rb +50 -0
- data/lib/mailgazelle/emails/email_record.rb +91 -0
- data/lib/mailgazelle/emails/email_status.rb +19 -0
- data/lib/mailgazelle/emails/queued_email.rb +41 -0
- data/lib/mailgazelle/emails_resource.rb +35 -0
- data/lib/mailgazelle/error.rb +69 -0
- data/lib/mailgazelle/error_mapper.rb +57 -0
- data/lib/mailgazelle/http/http_client.rb +84 -0
- data/lib/mailgazelle/http/net_http_transport.rb +55 -0
- data/lib/mailgazelle/http/request.rb +10 -0
- data/lib/mailgazelle/http/response.rb +8 -0
- data/lib/mailgazelle/mailer.rb +212 -0
- data/lib/mailgazelle/railtie.rb +14 -0
- data/lib/mailgazelle/version.rb +5 -0
- data/lib/mailgazelle.rb +49 -0
- metadata +72 -0
|
@@ -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
|