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.
@@ -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