thousandmails 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/LICENSE +70 -0
- data/README.md +390 -0
- data/lib/thousandmails/client.rb +159 -0
- data/lib/thousandmails/resources/emails.rb +172 -0
- data/lib/thousandmails/resources/logs.rb +204 -0
- data/lib/thousandmails/resources/realtime.rb +112 -0
- data/lib/thousandmails/resources/stats.rb +122 -0
- data/lib/thousandmails/utils/attachments.rb +291 -0
- data/lib/thousandmails/utils/constants.rb +100 -0
- data/lib/thousandmails/utils/errors.rb +197 -0
- data/lib/thousandmails/utils/http.rb +287 -0
- data/lib/thousandmails/utils/options.rb +29 -0
- data/lib/thousandmails/utils/stream.rb +252 -0
- data/lib/thousandmails/utils/transport.rb +177 -0
- data/lib/thousandmails/utils/validate.rb +263 -0
- data/lib/thousandmails/version.rb +6 -0
- data/lib/thousandmails.rb +36 -0
- metadata +58 -0
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
require "uri"
|
|
5
|
+
|
|
6
|
+
module ThousandMails
|
|
7
|
+
module Resources
|
|
8
|
+
# Email sending — the four send endpoints plus the message lookup.
|
|
9
|
+
#
|
|
10
|
+
# POST /client/sendmail
|
|
11
|
+
# POST /client/send/batch
|
|
12
|
+
# POST /client/sendmail/attachment
|
|
13
|
+
# POST /client/send/attachment/batch
|
|
14
|
+
# GET /client/messages/:id
|
|
15
|
+
class Emails
|
|
16
|
+
@warned_about_batch_idempotency = false
|
|
17
|
+
|
|
18
|
+
class << self
|
|
19
|
+
attr_accessor :warned_about_batch_idempotency
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def initialize(client)
|
|
23
|
+
@client = client
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def http
|
|
27
|
+
@client.http
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Send one email — raw (`subject` plus `text`/`html`) or from a saved
|
|
31
|
+
# template (`templateid` plus `templaterequiredfields`), never both.
|
|
32
|
+
#
|
|
33
|
+
# When the message carries `attachments` this routes to the multipart
|
|
34
|
+
# endpoint automatically, so callers have one method to remember.
|
|
35
|
+
#
|
|
36
|
+
# `send` shadows Ruby's Object#send on this object; `deliver` is an alias
|
|
37
|
+
# for anyone who would rather not.
|
|
38
|
+
#
|
|
39
|
+
# @param idempotency_key [String, false, nil] your own key, or false for none
|
|
40
|
+
# @param timeout [Integer, nil] overrides the client timeout, in milliseconds
|
|
41
|
+
def send(message = nil, idempotency_key: nil, timeout: nil, **fields)
|
|
42
|
+
message = Options.merge(message, fields)
|
|
43
|
+
|
|
44
|
+
if message.is_a?(Hash) && (message.key?(:attachments) || message.key?("attachments"))
|
|
45
|
+
return send_with_attachments(message, idempotency_key: idempotency_key, timeout: timeout)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
body = @client.validate_input ? Validate.message(message, **@client.validate_options) : message
|
|
49
|
+
|
|
50
|
+
http.request_object(
|
|
51
|
+
"POST", "/sendmail",
|
|
52
|
+
json: body,
|
|
53
|
+
idempotency_key: idempotency_key_for(idempotency_key),
|
|
54
|
+
timeout: timeout
|
|
55
|
+
)
|
|
56
|
+
end
|
|
57
|
+
alias deliver send
|
|
58
|
+
|
|
59
|
+
# Send up to 100 emails in one request. Each entry is validated and sent
|
|
60
|
+
# independently — a failure on one never rejects the batch, so always read
|
|
61
|
+
# the per-entry `outcome` in the response.
|
|
62
|
+
#
|
|
63
|
+
# The API ignores `Idempotency-Key` on batches, so a batch is never retried
|
|
64
|
+
# automatically after a 5xx.
|
|
65
|
+
def send_batch(messages = nil, idempotency_key: nil, timeout: nil, **fields)
|
|
66
|
+
# Folds `send_batch(messages: [...])` — the documented wrapper form —
|
|
67
|
+
# back into the payload; a plain array positional passes through.
|
|
68
|
+
messages = Options.merge(messages, fields)
|
|
69
|
+
|
|
70
|
+
if idempotency_key && !self.class.warned_about_batch_idempotency
|
|
71
|
+
self.class.warned_about_batch_idempotency = true
|
|
72
|
+
warn "ThousandMails: the API ignores Idempotency-Key on batch sends; " \
|
|
73
|
+
"the header will not be sent."
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
entries = if @client.validate_input
|
|
77
|
+
Validate.batch(messages, **@client.validate_options)
|
|
78
|
+
else
|
|
79
|
+
Validate.list_of(messages) || []
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
http.request_object("POST", "/send/batch", json: { "messages" => entries }, timeout: timeout)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Send one email with 1–5 attachments (multipart/form-data). Files may be
|
|
86
|
+
# paths on disk or bytes in memory — see ThousandMails::Attachments for the
|
|
87
|
+
# accepted forms. At least one file is required; without one, use `send`.
|
|
88
|
+
def send_with_attachments(message = nil, idempotency_key: nil, timeout: nil, **fields)
|
|
89
|
+
message = Options.merge(message, fields)
|
|
90
|
+
|
|
91
|
+
raise InvalidInputError, "A message object is required" unless message.is_a?(Hash)
|
|
92
|
+
|
|
93
|
+
unless message.key?(:attachments) || message.key?("attachments")
|
|
94
|
+
raise InvalidInputError.new(
|
|
95
|
+
"attachments is required — use send for a message without files",
|
|
96
|
+
{ field: "attachments" }
|
|
97
|
+
)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
attachments = message[:attachments] || message["attachments"]
|
|
101
|
+
rest = message.reject { |key, _| key.to_s == "attachments" }
|
|
102
|
+
|
|
103
|
+
body = @client.validate_input ? Validate.message(rest, **@client.validate_options) : rest
|
|
104
|
+
form, = Attachments.single_form(body, attachments)
|
|
105
|
+
|
|
106
|
+
http.request_object(
|
|
107
|
+
"POST", "/sendmail/attachment",
|
|
108
|
+
form: form,
|
|
109
|
+
idempotency_key: idempotency_key_for(idempotency_key),
|
|
110
|
+
timeout: timeout
|
|
111
|
+
)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Send up to 20 emails, each with its own files, in one multipart request.
|
|
115
|
+
# Every message must carry at least one attachment — the API rejects the
|
|
116
|
+
# entry otherwise. Files bind to their message by position.
|
|
117
|
+
def send_batch_with_attachments(messages = nil, timeout: nil, **fields)
|
|
118
|
+
messages = Options.merge(messages, fields)
|
|
119
|
+
|
|
120
|
+
entries = Validate.list_of(messages)
|
|
121
|
+
raise InvalidInputError, "messages must be a non-empty array" if entries.nil? || entries.empty?
|
|
122
|
+
|
|
123
|
+
bodies = []
|
|
124
|
+
files = []
|
|
125
|
+
|
|
126
|
+
entries.each do |entry|
|
|
127
|
+
record = entry.is_a?(Hash) ? entry : {}
|
|
128
|
+
files << (record[:attachments] || record["attachments"])
|
|
129
|
+
bodies << record.reject { |key, _| key.to_s == "attachments" }
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
validated = if @client.validate_input
|
|
133
|
+
Validate.batch(bodies, multipart: true, **@client.validate_options)
|
|
134
|
+
else
|
|
135
|
+
bodies
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
form, = Attachments.batch_form(validated, files)
|
|
139
|
+
|
|
140
|
+
http.request_object("POST", "/send/attachment/batch", form: form, timeout: timeout)
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Look up a send by the id returned from a send call, or by its SMTP
|
|
144
|
+
# message id (`<uuid@domain>` — encoded for you).
|
|
145
|
+
def get(message_id, timeout: nil)
|
|
146
|
+
if !message_id.is_a?(String) || message_id.strip.empty?
|
|
147
|
+
raise InvalidInputError, "A message id is required"
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
http.request_object(
|
|
151
|
+
"GET",
|
|
152
|
+
"/messages/#{Http.encode_path_segment(message_id)}",
|
|
153
|
+
timeout: timeout
|
|
154
|
+
)
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
private
|
|
158
|
+
|
|
159
|
+
# A single send is only replayable when it carries a key, so one is minted
|
|
160
|
+
# per call unless the caller supplies their own or opts out with false. The
|
|
161
|
+
# key scopes one logical send: a transport retry inside this call re-uses
|
|
162
|
+
# it and is de-duplicated server-side, while a later call gets a fresh one.
|
|
163
|
+
def idempotency_key_for(supplied)
|
|
164
|
+
return nil if supplied == false
|
|
165
|
+
return supplied.strip if supplied.is_a?(String) && !supplied.strip.empty?
|
|
166
|
+
return nil if !@client.auto_idempotency || http.max_retries.zero?
|
|
167
|
+
|
|
168
|
+
"sdk-#{SecureRandom.uuid}"
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
end
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "csv"
|
|
4
|
+
require "date"
|
|
5
|
+
require "time"
|
|
6
|
+
|
|
7
|
+
module ThousandMails
|
|
8
|
+
module Resources
|
|
9
|
+
# The delivery event log.
|
|
10
|
+
#
|
|
11
|
+
# GET /client/logs
|
|
12
|
+
# GET /client/logs/export (CSV)
|
|
13
|
+
# GET /client/logs/:id
|
|
14
|
+
class Logs
|
|
15
|
+
DAY_PATTERN = /\A\d{4}-\d{2}-\d{2}\z/
|
|
16
|
+
|
|
17
|
+
def initialize(client)
|
|
18
|
+
@client = client
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def http
|
|
22
|
+
@client.http
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# One page of events, newest first.
|
|
26
|
+
#
|
|
27
|
+
# Recognised keys: `to` (alias `recipient`), `search`, `event` (alias
|
|
28
|
+
# `type`), `from`, `until`, `page`, `pageSize` (alias `limit`, 1–200,
|
|
29
|
+
# default 50).
|
|
30
|
+
def list(query = nil, timeout: nil, **fields)
|
|
31
|
+
query = Options.merge(query, fields)
|
|
32
|
+
|
|
33
|
+
query = self.class.stringify(query)
|
|
34
|
+
page_size = query["pageSize"] || query["limit"]
|
|
35
|
+
bounds = Constants::LIMITS["logPageSize"]
|
|
36
|
+
|
|
37
|
+
unless page_size.nil?
|
|
38
|
+
valid = begin
|
|
39
|
+
number = Integer(page_size)
|
|
40
|
+
number >= bounds[:min] && number <= bounds[:max]
|
|
41
|
+
rescue ArgumentError, TypeError
|
|
42
|
+
false
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
unless valid
|
|
46
|
+
raise InvalidInputError.new(
|
|
47
|
+
"pageSize must be between #{bounds[:min]} and #{bounds[:max]}",
|
|
48
|
+
{ field: "pageSize", value: page_size }
|
|
49
|
+
)
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
params = self.class.filters(query).merge(
|
|
54
|
+
"page" => query["page"],
|
|
55
|
+
"pageSize" => page_size
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
http.request_object("GET", "/logs", query: params, timeout: timeout)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Walk every page of a filtered log, yielding one event at a time.
|
|
62
|
+
#
|
|
63
|
+
# Deep paging is bounded server-side: past ~10,000 rows the response comes
|
|
64
|
+
# back with `truncated: true` and iteration stops rather than looping on a
|
|
65
|
+
# page that can never advance. Narrow with filters instead of paging that
|
|
66
|
+
# far.
|
|
67
|
+
#
|
|
68
|
+
# client.logs.iterate({ event: "bounced" }).each { |event| ... }
|
|
69
|
+
def iterate(query = nil, timeout: nil, **fields)
|
|
70
|
+
query = Options.merge(query, fields)
|
|
71
|
+
return enum_for(:iterate, query, timeout: timeout) unless block_given?
|
|
72
|
+
|
|
73
|
+
query = self.class.stringify(query)
|
|
74
|
+
page_size = query["pageSize"] || query["limit"] || Constants::LIMITS["logPageSize"][:max]
|
|
75
|
+
page = (query["page"] || 1).to_i
|
|
76
|
+
|
|
77
|
+
loop do
|
|
78
|
+
# The loop's page/pageSize must win over whatever the caller passed, or
|
|
79
|
+
# a `page` in the query would pin every iteration to the same page.
|
|
80
|
+
result = list(query.merge("pageSize" => page_size, "page" => page), timeout: timeout)
|
|
81
|
+
|
|
82
|
+
events = result["events"] || []
|
|
83
|
+
events.each { |event| yield event }
|
|
84
|
+
|
|
85
|
+
break if !result["hasMore"] || result["truncated"] || events.empty?
|
|
86
|
+
|
|
87
|
+
page += 1
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Fetch one event by its `eventId`.
|
|
92
|
+
def get(event_id, timeout: nil)
|
|
93
|
+
if !event_id.is_a?(String) || event_id.strip.empty?
|
|
94
|
+
raise InvalidInputError, "An eventId is required"
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
data = http.request_object(
|
|
98
|
+
"GET",
|
|
99
|
+
"/logs/#{Http.encode_path_segment(event_id)}",
|
|
100
|
+
timeout: timeout
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
# The endpoint wraps its payload; unwrap so it matches every other event shape.
|
|
104
|
+
data["event"].is_a?(Hash) ? data["event"] : data
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# The same filtered log as CSV (up to 5000 rows), returned as a string.
|
|
108
|
+
# Paging params do not apply. Columns: eventId, occurredAt, type,
|
|
109
|
+
# recipient, senderEmail, subject, messageId, queueId, dsn, ip.
|
|
110
|
+
def export(query = nil, timeout: nil, **fields)
|
|
111
|
+
query = Options.merge(query, fields)
|
|
112
|
+
|
|
113
|
+
http.request(
|
|
114
|
+
"GET", "/logs/export",
|
|
115
|
+
query: self.class.filters(self.class.stringify(query)),
|
|
116
|
+
parse: :text,
|
|
117
|
+
timeout: timeout
|
|
118
|
+
)[:data]
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Convenience: export straight to a file on disk. Returns the row count.
|
|
122
|
+
def export_to_file(file_path, query = nil, timeout: nil, **fields)
|
|
123
|
+
content = export(Options.merge(query, fields), timeout: timeout)
|
|
124
|
+
File.write(file_path, content, mode: "wb")
|
|
125
|
+
self.class.count_rows(content)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
class << self
|
|
129
|
+
# Data rows in a CSV export, excluding the header.
|
|
130
|
+
#
|
|
131
|
+
# Counting line breaks would overcount: the server quotes any cell
|
|
132
|
+
# holding a newline (a subject can), so the CSV is parsed rather than
|
|
133
|
+
# split.
|
|
134
|
+
def count_rows(content)
|
|
135
|
+
return 0 if content.nil? || content.strip.empty?
|
|
136
|
+
|
|
137
|
+
rows = CSV.parse(content).reject { |row| row.nil? || row.empty? || row == [nil] }
|
|
138
|
+
[0, rows.length - 1].max
|
|
139
|
+
rescue CSV::MalformedCSVError
|
|
140
|
+
0
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# `from`/`until` accept a YYYY-MM-DD day (widened to cover it), an ISO
|
|
144
|
+
# timestamp, or a Date/Time. The API ignores an unparsable value rather
|
|
145
|
+
# than rejecting it, which silently widens the window — so it is checked
|
|
146
|
+
# here.
|
|
147
|
+
def to_bound(value, field)
|
|
148
|
+
return nil if value.nil? || value == ""
|
|
149
|
+
# A wall-clock instant is pinned to UTC, which is the timezone the API
|
|
150
|
+
# reads the window in. Taking it as-is would shift the boundary by the
|
|
151
|
+
# caller's offset.
|
|
152
|
+
return value.getutc.iso8601 if value.is_a?(Time)
|
|
153
|
+
return value.to_time.getutc.iso8601 if value.is_a?(DateTime)
|
|
154
|
+
return value.strftime("%Y-%m-%d") if value.is_a?(Date)
|
|
155
|
+
|
|
156
|
+
text = value.to_s.strip
|
|
157
|
+
# Shape and calendar both — "2026-13-45" is the right shape and not a
|
|
158
|
+
# real day, and the API would ignore it and widen the window silently.
|
|
159
|
+
return text if DAY_PATTERN.match?(text) && Resources::Stats.calendar_day?(text)
|
|
160
|
+
|
|
161
|
+
begin
|
|
162
|
+
# Time.iso8601, not DateTime.parse. Ruby's general parser is lenient
|
|
163
|
+
# enough to read "last-tuesday" as a real date and "2026-13-45" as a
|
|
164
|
+
# valid one, which would defeat the whole point of this check: the
|
|
165
|
+
# API ignores what it cannot parse and silently widens the window, so
|
|
166
|
+
# anything it would not accept has to be refused here.
|
|
167
|
+
Time.iso8601(text)
|
|
168
|
+
rescue ArgumentError, TypeError
|
|
169
|
+
raise InvalidInputError.new(
|
|
170
|
+
"#{field} must be YYYY-MM-DD, an ISO-8601 timestamp, or a Date/Time — " \
|
|
171
|
+
"the API ignores anything it cannot parse",
|
|
172
|
+
{ field: field, value: text }
|
|
173
|
+
)
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
text
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def filters(query)
|
|
180
|
+
event = query["event"] || query["type"]
|
|
181
|
+
|
|
182
|
+
if !event.nil? && !Constants::EVENT_TYPES.include?(event.to_s)
|
|
183
|
+
raise InvalidInputError.new(
|
|
184
|
+
"event must be one of: #{Constants::EVENT_TYPES.join(', ')}",
|
|
185
|
+
{ field: "event", value: event }
|
|
186
|
+
)
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
{
|
|
190
|
+
"to" => query["to"] || query["recipient"],
|
|
191
|
+
"search" => query["search"],
|
|
192
|
+
"event" => event,
|
|
193
|
+
"from" => to_bound(query["from"], "from"),
|
|
194
|
+
"until" => to_bound(query["until"], "until")
|
|
195
|
+
}
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def stringify(query)
|
|
199
|
+
(query || {}).each_with_object({}) { |(key, value), out| out[key.to_s] = value }
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
end
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ThousandMails
|
|
4
|
+
module Resources
|
|
5
|
+
# Live counters, feeds and the event stream.
|
|
6
|
+
#
|
|
7
|
+
# GET /client/realtime/stats
|
|
8
|
+
# GET /client/realtime/per-minute
|
|
9
|
+
# GET /client/realtime/per-second
|
|
10
|
+
# GET /client/realtime/activity
|
|
11
|
+
# GET /client/realtime/stream (Server-Sent Events)
|
|
12
|
+
class Realtime
|
|
13
|
+
def initialize(client)
|
|
14
|
+
@client = client
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def http
|
|
18
|
+
@client.http
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Every metric summed over the last N minutes (1–120, default 60).
|
|
22
|
+
def stats(params = nil, timeout: nil, **fields)
|
|
23
|
+
params = Options.merge(params, fields)
|
|
24
|
+
|
|
25
|
+
params = self.class.stringify(params)
|
|
26
|
+
http.request_object(
|
|
27
|
+
"GET", "/realtime/stats",
|
|
28
|
+
query: { "minutes" => self.class.bounded(params["minutes"], "minutes", "realtimeMinutes") },
|
|
29
|
+
timeout: timeout
|
|
30
|
+
)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# The last N per-minute buckets, oldest first (1–120, default 60).
|
|
34
|
+
def per_minute(params = nil, timeout: nil, **fields)
|
|
35
|
+
params = Options.merge(params, fields)
|
|
36
|
+
|
|
37
|
+
params = self.class.stringify(params)
|
|
38
|
+
http.request_object(
|
|
39
|
+
"GET", "/realtime/per-minute",
|
|
40
|
+
query: { "minutes" => self.class.bounded(params["minutes"], "minutes", "realtimeMinutes") },
|
|
41
|
+
timeout: timeout
|
|
42
|
+
)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Per-second buckets over the last N seconds (1–600, default 120),
|
|
46
|
+
# zero-filled so the series is continuous.
|
|
47
|
+
#
|
|
48
|
+
# The API derives these from at most 1000 raw events, newest first, so an
|
|
49
|
+
# account busier than that in the window will see the oldest seconds
|
|
50
|
+
# reported as idle. Narrow the window when volume is high.
|
|
51
|
+
def per_second(params = nil, timeout: nil, **fields)
|
|
52
|
+
params = Options.merge(params, fields)
|
|
53
|
+
|
|
54
|
+
params = self.class.stringify(params)
|
|
55
|
+
http.request_object(
|
|
56
|
+
"GET", "/realtime/per-second",
|
|
57
|
+
query: { "seconds" => self.class.bounded(params["seconds"], "seconds", "realtimeSeconds") },
|
|
58
|
+
timeout: timeout
|
|
59
|
+
)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# The most recent events, newest first (1–100, default 20).
|
|
63
|
+
def activity(params = nil, timeout: nil, **fields)
|
|
64
|
+
params = Options.merge(params, fields)
|
|
65
|
+
|
|
66
|
+
params = self.class.stringify(params)
|
|
67
|
+
http.request_object(
|
|
68
|
+
"GET", "/realtime/activity",
|
|
69
|
+
query: { "limit" => self.class.bounded(params["limit"], "limit", "activityLimit") },
|
|
70
|
+
timeout: timeout
|
|
71
|
+
)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Open the live event stream.
|
|
75
|
+
#
|
|
76
|
+
# Returns an EventStream that dispatches to handlers through `listen`, or
|
|
77
|
+
# yields email events through `events`. Reconnects with backoff unless
|
|
78
|
+
# `reconnect: false` or the failure is an auth error, which will never
|
|
79
|
+
# resolve itself.
|
|
80
|
+
#
|
|
81
|
+
# Always call `close` (or `break` out of the loop) — the connection is held
|
|
82
|
+
# open by design and blocks the process otherwise.
|
|
83
|
+
def stream(**options)
|
|
84
|
+
EventStream.new(http, **options)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
class << self
|
|
88
|
+
# The API clamps out-of-range values silently, which makes a typo look
|
|
89
|
+
# like data. Rejecting locally keeps the window a caller asked for and
|
|
90
|
+
# the one they get the same thing.
|
|
91
|
+
def bounded(value, name, limit_key)
|
|
92
|
+
return nil if value.nil? || value == ""
|
|
93
|
+
|
|
94
|
+
bounds = Constants::LIMITS[limit_key]
|
|
95
|
+
number = Integer(value)
|
|
96
|
+
raise ArgumentError if number < bounds[:min] || number > bounds[:max]
|
|
97
|
+
|
|
98
|
+
number
|
|
99
|
+
rescue ArgumentError, TypeError
|
|
100
|
+
raise InvalidInputError.new(
|
|
101
|
+
"#{name} must be a number between #{bounds[:min]} and #{bounds[:max]}",
|
|
102
|
+
{ field: name, value: value }
|
|
103
|
+
)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def stringify(params)
|
|
107
|
+
(params || {}).each_with_object({}) { |(key, value), out| out[key.to_s] = value }
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "date"
|
|
4
|
+
|
|
5
|
+
module ThousandMails
|
|
6
|
+
module Resources
|
|
7
|
+
# Aggregate statistics.
|
|
8
|
+
#
|
|
9
|
+
# GET /client/stats/summary
|
|
10
|
+
# GET /client/stats/timeseries
|
|
11
|
+
# GET /client/stats/by-sender
|
|
12
|
+
# GET /client/stats/by-tag
|
|
13
|
+
#
|
|
14
|
+
# `from`/`to` are YYYY-MM-DD in UTC and default server-side to the last 30
|
|
15
|
+
# days. A Date or Time is accepted here and converted, because passing one to
|
|
16
|
+
# a query string otherwise yields a form the API silently ignores.
|
|
17
|
+
class Stats
|
|
18
|
+
DAY_PATTERN = /\A\d{4}-\d{2}-\d{2}\z/
|
|
19
|
+
|
|
20
|
+
def initialize(client)
|
|
21
|
+
@client = client
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def http
|
|
25
|
+
@client.http
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Totals for the window, the preceding window of equal length (for
|
|
29
|
+
# period-over-period deltas), and the derived rates.
|
|
30
|
+
def summary(params = nil, timeout: nil, **fields)
|
|
31
|
+
params = Options.merge(params, fields)
|
|
32
|
+
|
|
33
|
+
http.request_object("GET", "/stats/summary", query: self.class.range(params), timeout: timeout)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Per-interval buckets across the window.
|
|
37
|
+
# `interval`: day (default), week or month.
|
|
38
|
+
def timeseries(params = nil, timeout: nil, **fields)
|
|
39
|
+
params = Options.merge(params, fields)
|
|
40
|
+
|
|
41
|
+
params = self.class.stringify(params)
|
|
42
|
+
interval = params["interval"]
|
|
43
|
+
|
|
44
|
+
if !interval.nil? && !Constants::INTERVALS.include?(interval.to_s)
|
|
45
|
+
raise InvalidInputError.new(
|
|
46
|
+
"interval must be one of: #{Constants::INTERVALS.join(', ')}",
|
|
47
|
+
{ field: "interval", value: interval }
|
|
48
|
+
)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
query = self.class.range(params).merge("interval" => interval)
|
|
52
|
+
http.request_object("GET", "/stats/timeseries", query: query, timeout: timeout)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Totals grouped by sender address.
|
|
56
|
+
def by_sender(params = nil, timeout: nil, **fields)
|
|
57
|
+
params = Options.merge(params, fields)
|
|
58
|
+
|
|
59
|
+
http.request_object("GET", "/stats/by-sender", query: self.class.range(params), timeout: timeout)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Send outcomes grouped by the record's `tag`, optionally narrowed to a list.
|
|
63
|
+
#
|
|
64
|
+
# Note: no send endpoint accepts a tag yet, so in practice every send is
|
|
65
|
+
# untagged and the whole window collapses into a single `tag: nil` row.
|
|
66
|
+
def by_tag(params = nil, timeout: nil, **fields)
|
|
67
|
+
params = Options.merge(params, fields)
|
|
68
|
+
|
|
69
|
+
params = self.class.stringify(params)
|
|
70
|
+
tags = params["tags"]
|
|
71
|
+
tags = tags.join(",") if tags.is_a?(Array)
|
|
72
|
+
|
|
73
|
+
query = self.class.range(params).merge("tags" => tags)
|
|
74
|
+
http.request_object("GET", "/stats/by-tag", query: query, timeout: timeout)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
class << self
|
|
78
|
+
# Normalise one end of a date range to YYYY-MM-DD.
|
|
79
|
+
def to_day(value, field)
|
|
80
|
+
return nil if value.nil? || value == ""
|
|
81
|
+
# `from`/`to` are days in UTC, so a wall-clock instant is converted
|
|
82
|
+
# rather than read off as-is: 05:00 in +05:30 is the previous day to
|
|
83
|
+
# the API, and taking the local day would silently shift the window.
|
|
84
|
+
return value.getutc.strftime("%Y-%m-%d") if value.is_a?(Time)
|
|
85
|
+
return value.to_time.getutc.strftime("%Y-%m-%d") if value.is_a?(DateTime)
|
|
86
|
+
return value.strftime("%Y-%m-%d") if value.is_a?(Date)
|
|
87
|
+
|
|
88
|
+
text = value.to_s.strip
|
|
89
|
+
# Shape and calendar both: "2026-13-45" is the right shape and not a
|
|
90
|
+
# real day, and the API would ignore it and quietly fall back to its
|
|
91
|
+
# default window rather than say so.
|
|
92
|
+
unless DAY_PATTERN.match?(text) && calendar_day?(text)
|
|
93
|
+
raise InvalidInputError.new(
|
|
94
|
+
"#{field} must be a real YYYY-MM-DD date or a Date object — the API " \
|
|
95
|
+
"silently ignores anything else and falls back to its default window",
|
|
96
|
+
{ field: field, value: text }
|
|
97
|
+
)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
text
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# True when the text names a day that exists.
|
|
104
|
+
def calendar_day?(text)
|
|
105
|
+
Date.strptime(text, "%Y-%m-%d")
|
|
106
|
+
true
|
|
107
|
+
rescue ArgumentError, TypeError
|
|
108
|
+
false
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def range(params)
|
|
112
|
+
params = stringify(params)
|
|
113
|
+
{ "from" => to_day(params["from"], "from"), "to" => to_day(params["to"], "to") }
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def stringify(params)
|
|
117
|
+
(params || {}).each_with_object({}) { |(key, value), out| out[key.to_s] = value }
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|