mailtea 0.2.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: b4a24f347aac9fc071ff85740ed202b8469d388c41d088fe5a63deab3295d52c
4
+ data.tar.gz: 11722976c96f5b69ee607f50f33658fe95ba262d0675942fd9d914f01772fc66
5
+ SHA512:
6
+ metadata.gz: 664a1e7747c2e1ce0d2f25b84c54a485f8ac58246cfb2c8cff8cfe60b32e2c31ee32ca19373d73599a3e1d8eb65fa13207c009406dbffdb510fd3e16899e34b3
7
+ data.tar.gz: 60f69633ac8f6c5fc8ec8e7d8f7f6e494c9a366820f0ea6408936c32631dd4f0d1a46cc1913288b8d85e8a248e498335e95f4b43d87b9bd6306008b5e8656b5d
data/CHANGELOG.md ADDED
@@ -0,0 +1,67 @@
1
+ # Changelog
2
+
3
+ All notable changes to the `mailtea` Ruby gem are documented here.
4
+
5
+ ## Unreleased
6
+
7
+ - Added: `mailtea.domains.update(id, tracking_subdomain: nil)` removes a
8
+ tracking subdomain. The domain's links go back to being served from the
9
+ Mailtea host. Links in mail you have already sent point at the old hostname
10
+ and stop resolving — there is no way to reinstate them. Only the query string
11
+ drops nils, so the removal travels in the body as an explicit null; leaving
12
+ the key out and passing nil are different requests. An empty string is
13
+ neither: it is refused with `tracking_subdomain_invalid`.
14
+ - Changed: the `MX` row in `records` now reports what the last verify found,
15
+ instead of reading `pending` on every request but the verify itself. A domain
16
+ nobody has verified reads `not_started`.
17
+
18
+ ## 0.2.0 (2026-09-03)
19
+
20
+ - Added: the domain claims resource — `mailtea.domains.claims.create`, `.get`,
21
+ `.verify` and `.cancel`. When adding a domain is refused because the host is
22
+ connected to another publication, publish one TXT record to prove you control
23
+ its DNS and the domain moves to you.
24
+ - Documented: domains take `region` (fixed at creation), `tls` and
25
+ `tracking_subdomain` on create, and the list filters on `region` and `status`.
26
+ This SDK forwards whatever parameters you pass, so these worked already — this
27
+ release is where they are stated and covered by tests.
28
+
29
+ ## 0.1.0 (2026-08-27)
30
+
31
+ First release. A thin, zero-dependency wrapper over the
32
+ [Mailtea REST API](https://docs.mailtea.app/docs/api-reference), built on
33
+ `net/http` and `json` from the standard library. Ruby 3.0+.
34
+
35
+ ### Added
36
+
37
+ - **`Mailtea::Client`** — reads `MAILTEA_API_KEY` and the optional
38
+ `MAILTEA_API_BASE_URL`, or takes both explicitly. The HTTP transport is
39
+ injectable, so tests need no network and an app can route requests through its
40
+ own instrumented stack.
41
+
42
+ - **Transactional email.** `emails.send`, `batch`, `get`, `list`, `analytics`,
43
+ `update`, `reschedule` and `cancel` — plus `emails.inbound` for received mail
44
+ (`list`, `get`, `reply`, and `attachments.list` / `attachments.get`).
45
+ `emails.get` adds a `status` alias of the wire's `last_event`. The API caps a
46
+ message at 50 recipients combined across `to`, `cc` and `bcc`.
47
+
48
+ - **Audience.** `contacts` (`create`/`upsert`, `list`, `get`, `update`,
49
+ `delete`), `segments`, `topics`, `senders`, `suppressions` (including
50
+ `export`, which returns raw CSV text), and `contact_properties`.
51
+
52
+ - **Content.** `posts` (`create`, `send`, `send_test`, `list`, `get`, `update`,
53
+ `delete`), `templates` (including `render`, `publish`/`unpublish`, `versions`,
54
+ `restore_version` and `duplicate`), and `assets` for the publication's image
55
+ library — raw bytes are base64-encoded for you.
56
+
57
+ - **Platform.** `domains` (+ `domains.tracking`), `webhooks`, `api_keys`,
58
+ `automations`, `automation_runs`, `events` and `event_definitions`.
59
+
60
+ - **`Mailtea.verify_webhook_signature` / `Mailtea.sign_webhook`** — Standard
61
+ Webhooks verification with replay protection and a constant-time compare,
62
+ checked in the test suite against signatures produced by the platform's own
63
+ TypeScript signer.
64
+
65
+ - **`Mailtea::Error`** on every failure, carrying `status` (0 for a client-side
66
+ fault such as an unreachable API), `message`, `code`, `details` and
67
+ `request_id`.
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Mailtea
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,234 @@
1
+ # mailtea-ruby
2
+
3
+ The official Ruby SDK for [Mailtea](https://mailtea.app) — a thin, typed wrapper
4
+ over the [REST API](https://docs.mailtea.app/docs/api-reference). Ruby 3.0+, no
5
+ runtime dependencies.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ gem install mailtea
11
+ ```
12
+
13
+ Or in a `Gemfile`:
14
+
15
+ ```ruby
16
+ gem "mailtea", "~> 0.1"
17
+ ```
18
+
19
+ ## Usage
20
+
21
+ ```ruby
22
+ require "mailtea"
23
+
24
+ mailtea = Mailtea::Client.new # reads MAILTEA_API_KEY
25
+
26
+ sent = mailtea.emails.send(
27
+ from: "you@yourdomain.com",
28
+ to: "recipient@example.com",
29
+ subject: "Hello from Mailtea",
30
+ html: "<p>Your first email, sent with <strong>Mailtea</strong>.</p>"
31
+ )
32
+ puts sent["id"]
33
+
34
+ email = mailtea.emails.get(sent["id"])
35
+ puts email["status"] # "queued" right after a send; delivery is asynchronous
36
+ ```
37
+
38
+ Responses are the wire JSON as a Hash that also takes Symbol keys, so
39
+ `email["id"]` and `email[:id]` are the same lookup. Keys are never renamed: what
40
+ the API reference documents is what you index.
41
+
42
+ The documented fields of a request are named keyword arguments; anything else
43
+ the API accepts can be passed as an extra keyword, or as a leading wire-format
44
+ Hash, and both styles can be mixed:
45
+
46
+ ```ruby
47
+ mailtea.emails.send({ "from" => "you@yourdomain.com", "subject" => "Hello" },
48
+ to: "recipient@example.com",
49
+ html: "<p>Hi</p>")
50
+ ```
51
+
52
+ Named keywords cover `emails.send` / `batch` / `update`, `contacts.create` /
53
+ `update`, `posts.create` / `send` / `send_test`, `topics.create` and
54
+ `emails.inbound.reply`. Every other method takes the Hash-or-keywords form for
55
+ the whole payload — the API reference is the field list.
56
+
57
+ A keyword you do not pass is left out of the request; one you pass as `nil` is
58
+ sent as JSON `null`, which is how the API clears a nullable field:
59
+
60
+ ```ruby
61
+ mailtea.segments.update(id, publication_id: pub) # leaves the filter alone
62
+ mailtea.segments.update(id, publication_id: pub, status_filter: nil) # clears it
63
+ ```
64
+
65
+ `emails.send` also takes `tags`, custom `headers`, `attachments` and
66
+ `scheduled_at`. Attachments carry base64 `content`; set a `content_id` (plus
67
+ `content_type`) to embed an inline image referenced by `cid:` in the HTML:
68
+
69
+ ```ruby
70
+ mailtea.emails.send(
71
+ from: "you@yourdomain.com",
72
+ to: "recipient@example.com",
73
+ subject: "Your receipt",
74
+ html: '<p>Thanks!</p><img src="cid:logo" />',
75
+ tags: [{ name: "category", value: "receipt" }],
76
+ attachments: [
77
+ { filename: "receipt.pdf", content: pdf_base64 },
78
+ { filename: "logo.png", content: logo_base64,
79
+ content_type: "image/png", content_id: "logo" } # inline
80
+ ]
81
+ )
82
+ ```
83
+
84
+ `to`, `cc`, `bcc` and `reply_to` each take a single address or an Array. The API
85
+ caps a message at **50 recipients combined** across `to` + `cc` + `bcc`.
86
+
87
+ ## Configuration
88
+
89
+ | What | How |
90
+ |---|---|
91
+ | API key | `Mailtea::Client.new("mt_pat_…")`, or the `MAILTEA_API_KEY` environment variable |
92
+ | Base URL | `Mailtea::Client.new(key, base_url: "…")`, or `MAILTEA_API_BASE_URL`. Defaults to `https://api.mailtea.app` |
93
+ | HTTP transport | `Mailtea::Client.new(key, transport: my_transport)` |
94
+
95
+ ```bash
96
+ export MAILTEA_API_KEY="mt_pat_xxxxxxxx"
97
+ export MAILTEA_API_BASE_URL="http://127.0.0.1:7787" # self-hosted or local only
98
+ ```
99
+
100
+ A transport is anything that responds to
101
+ `call(method, url, headers, body) -> Mailtea::HttpResponse`. Pass one to record
102
+ requests in tests, route through a proxy, or reuse a connection pool —
103
+ everything above the wire stays the same:
104
+
105
+ ```ruby
106
+ recorder = lambda do |method, url, headers, body|
107
+ puts "#{method} #{url}"
108
+ Mailtea::Transport.call(method, url, headers, body)
109
+ end
110
+
111
+ mailtea = Mailtea::Client.new(ENV["MAILTEA_API_KEY"], transport: recorder)
112
+ ```
113
+
114
+ ## API
115
+
116
+ | Method | Description |
117
+ | --- | --- |
118
+ | `emails.send(params)` | Send a transactional email → `{ "id" => … }` |
119
+ | `emails.batch(emails)` | Send up to 100 emails → `{ "data" => [{ "id" => … }] }` |
120
+ | `emails.get(id)` | Retrieve an email and its delivery status |
121
+ | `emails.list(params = nil)` | List emails → `{ "data", "total", "limit", "offset", "has_more" }` |
122
+ | `emails.update(id, params)` | Reschedule a scheduled email |
123
+ | `emails.reschedule(id, scheduled_at)` | Convenience wrapper over `update` |
124
+ | `emails.cancel(id)` | Cancel a scheduled email (`POST /v1/emails/:id/cancel`) |
125
+ | `emails.analytics(params = nil)` | Aggregate transactional metrics over an optional date window |
126
+ | `emails.inbound.list(params = nil)` | List received emails in a publication (cursor-paginated) |
127
+ | `emails.inbound.get(id)` | Retrieve a received email with body, headers and attachments |
128
+ | `emails.inbound.reply(id, params)` | Reply to a received email (threads by construction) |
129
+ | `emails.inbound.attachments.list(id)` | List a received email's attachments (signed download URLs) |
130
+ | `emails.inbound.attachments.get(id, attachment_id)` | Retrieve one inbound attachment |
131
+ | `contacts.create / upsert / list / get / update / delete` | Manage audience contacts (`upsert` = `create`; the endpoint upserts) |
132
+ | `posts.create(params)` | Create a newsletter post (draft, or `send: true`) → `{ "id" => … }` |
133
+ | `posts.send(id, params = nil)` | Send a draft post to the audience, now or at `scheduled_at` |
134
+ | `posts.send_test(id, params)` | Send a `[TEST]` copy of a post → `{ "sent_to", "failed_to" }` |
135
+ | `posts.list / get / update / delete` | Manage posts (offset-paginated list) |
136
+ | `segments.create / list / get / update / delete` | Manage audience segments |
137
+ | `topics.create / list / get / update / delete` | Manage topic definitions (`visibility: "public"` → shown on the reader preference page) |
138
+ | `senders.create / list / get / update / delete` | Manage named From identities (`email` immutable) |
139
+ | `templates.create / list / get / update / publish / unpublish / duplicate / delete` | Manage reusable email templates |
140
+ | `templates.render(params)` | Render a spec to HTML without saving → `{ "html", "text" }` |
141
+ | `templates.versions(id, params = nil)` | List a template's design history, newest first (metadata only) |
142
+ | `templates.restore_version(id, version, params = nil)` | Put an older design back — a content write, so the template returns to **draft** |
143
+ | `assets.upload / list / delete` | The publication's image library (raw bytes are base64-encoded for you) |
144
+ | `suppressions.list / add / remove` | Manage the team-wide do-not-send list |
145
+ | `suppressions.export` | Export the whole suppression list as CSV (raw text) |
146
+ | `domains.create / list / get / verify / update / delete` | Manage sending domains (add, read DNS records, verify) |
147
+ | `domains.tracking.create / list / verify / delete` | Manage CNAME tracking sub-domains under a domain |
148
+ | `webhooks.create / list / get / update / delete` | Manage outbound event subscriptions |
149
+ | `contact_properties.create / list / update / delete` | Manage custom contact fields (team-scoped) |
150
+ | `api_keys.create / list / revoke` | Manage API keys (`settings:write`) |
151
+ | `automations.create / list / get / update / delete` | Manage automation graphs (`steps` + optional `connections`) |
152
+ | `automations.validate(params)` | Dry-run a graph → `{ "valid", "issues" }` (no automation needed) |
153
+ | `automations.activate / pause / archive` | Lifecycle (`cancel_runs` defaults **false** on pause, **true** on archive) |
154
+ | `automations.versions(id, …)` / `automations.version(id, version, …)` | List stored versions; retrieve one with its graph |
155
+ | `automations.metrics(id, params = nil)` | Per-step funnel counts and branch splits (test runs excluded) |
156
+ | `automations.test(id, params)` | One test run against a real contact — **sends real, billed email** |
157
+ | `automation_runs.list / get / cancel` | Inspect and cancel runs (a run pins the version it started on) |
158
+ | `events.send(params)` | Record a custom event → `{ "enrolled_automations", "resumed_runs" }` |
159
+ | `events.list(params)` | List recorded events (cursor-paginated) |
160
+ | `event_definitions.create / list / get / update / delete` | Manage the event catalog (`name` immutable) |
161
+
162
+ Audience, content and platform resources are scoped to a publication — pass
163
+ `publication_id`. `suppressions` and `contact_properties` are team-scoped and
164
+ take none.
165
+
166
+ `emails.send`, `posts.send` and `events.send` deliberately shadow `Object#send`
167
+ on their resource object, because the API's verb is "send". Ruby's `__send__`
168
+ is untouched, so metaprogramming still works.
169
+
170
+ ## Webhooks
171
+
172
+ Mailtea signs every outbound webhook with
173
+ [Standard Webhooks](https://www.standardwebhooks.com/).
174
+ `Mailtea.verify_webhook_signature` checks the signature and rejects replays.
175
+ Pass the **raw** request body (not re-serialized JSON) and the endpoint's
176
+ `whsec_…` signing secret:
177
+
178
+ ```ruby
179
+ ok = Mailtea.verify_webhook_signature(
180
+ signing_secret, # whsec_… from webhooks.create
181
+ request.headers["webhook-id"],
182
+ request.headers["webhook-timestamp"],
183
+ request.raw_post, # exact bytes received
184
+ request.headers["webhook-signature"]
185
+ )
186
+ head :unauthorized unless ok
187
+ ```
188
+
189
+ The timestamp window defaults to 5 minutes each way and both it and the clock
190
+ are injectable: `tolerance_seconds:` and `now:`.
191
+ `Mailtea.sign_webhook(secret, msg_id, timestamp, payload)` produces the same
192
+ header, which is how you fake a delivery in your own tests.
193
+
194
+ ## Errors
195
+
196
+ Every failure raises `Mailtea::Error` — an API error and a dropped connection
197
+ alike, so one `rescue` covers the send path:
198
+
199
+ ```ruby
200
+ begin
201
+ mailtea.emails.send(from: from, to: to, subject: "Hi", html: "<p>Hi</p>")
202
+ rescue Mailtea::Error => e
203
+ warn e.message # the API's own message, e.g. "Domain not verified"
204
+ e.status # 422 — or 0 when the request never left the process
205
+ e.code # machine-readable code, when the API sends one
206
+ e.details # validation issues, when the API sends them
207
+ e.request_id # the x-request-id header; quote it in support tickets
208
+ end
209
+ ```
210
+
211
+ `status` is `0` for a client-side fault: a missing API key, an unreachable host,
212
+ a timeout. Branch on `code` rather than the message — copy changes, codes do not.
213
+
214
+ ## Local development
215
+
216
+ ```bash
217
+ bundle install
218
+ bundle exec rake test
219
+ ```
220
+
221
+ The tests run against a bundled mock API on an ephemeral port
222
+ (`test/mock_mailtea.rb`), so they need no credentials and make no network calls.
223
+
224
+ To run against a local Mailtea instead:
225
+
226
+ ```bash
227
+ export MAILTEA_API_KEY="mt_pat_…"
228
+ export MAILTEA_API_BASE_URL="http://127.0.0.1:7787"
229
+ ruby -Ilib -e 'require "mailtea"; p Mailtea::Client.new.emails.list(limit: 1)'
230
+ ```
231
+
232
+ ## License
233
+
234
+ MIT. See [LICENSE](LICENSE).
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "resource"
4
+
5
+ module Mailtea
6
+ # The +api_keys+ resource. Reach it at <tt>mailtea.api_keys</tt>.
7
+ #
8
+ # Requires a token with +settings:write+. A key can never be granted scopes the
9
+ # calling token does not already hold.
10
+ class ApiKeys < Resource
11
+ # Create an API key. The +token+ is returned ONCE — store it securely.
12
+ #
13
+ # Takes +name+, optional +permission+ ("full_access" or "sending_access"),
14
+ # and optional +domain_id+.
15
+ def create(params = nil, **fields)
16
+ request("POST", "/v1/api-keys", payload(params, fields))
17
+ end
18
+
19
+ # List API keys (token values are never returned).
20
+ def list
21
+ request("GET", "/v1/api-keys")
22
+ end
23
+
24
+ # Revoke (delete) an API key by id.
25
+ def revoke(id)
26
+ request("DELETE", "/v1/api-keys/" + escape(id))
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "resource"
4
+
5
+ module Mailtea
6
+ # The +assets+ resource (a publication's image library). Reach it at
7
+ # <tt>mailtea.assets</tt>.
8
+ #
9
+ # An email or site image needs an absolute URL, so this is how a picture that
10
+ # is not already in the library gets into one. Pointing an image at a host you
11
+ # do not control breaks the day that host moves the file.
12
+ #
13
+ # PNG, JPEG, GIF, WebP or SVG, 5 MB per image. The bytes are checked against
14
+ # the declared +content_type+, so a mislabelled file is rejected rather than
15
+ # stored. SVG is served with <tt>Content-Security-Policy: sandbox</tt>, which
16
+ # is what makes hosting one from the publication's own domain safe.
17
+ #
18
+ # asset = mailtea.assets.upload(
19
+ # publication_id: "pub_123",
20
+ # content: File.binread("hero.png"), # base64-encoded for you
21
+ # content_type: "image/png",
22
+ # filename: "hero.png"
23
+ # )
24
+ # asset["url"] # -> use as an image block's src
25
+ class Assets < Resource
26
+ # Upload an image. Takes +publication_id+, +content+, +content_type+ and
27
+ # +filename+.
28
+ #
29
+ # +content+ takes raw bytes — read the file with File.binread — and is
30
+ # base64-encoded for you, or a String you already encoded, which is sent
31
+ # untouched.
32
+ def upload(params = nil, **fields)
33
+ body = payload(params, fields)
34
+ content = body["content"]
35
+ body["content"] = [content].pack("m0") if content.is_a?(String) && raw_bytes?(content)
36
+ request("POST", "/v1/assets", body)
37
+ end
38
+
39
+ # List the library, newest first. Filters: +publication_id+ (required),
40
+ # +search+ (file name), +limit+ (1-200, default 100).
41
+ def list(params = nil, **filters)
42
+ request("GET", "/v1/assets" + query(payload(params, filters)))
43
+ end
44
+
45
+ # Retire an asset.
46
+ #
47
+ # The stored file is KEPT and its URL keeps resolving, so images inside
48
+ # already-sent emails do not break. This hides the asset from the library —
49
+ # it does not remove it from any email, template or page referencing it.
50
+ def delete(id, params = nil, **filters)
51
+ request("DELETE", "/v1/assets/" + escape(id) + query(payload(params, filters)))
52
+ end
53
+
54
+ private
55
+
56
+ # Ruby has no separate bytes type, so image bytes and an already-encoded
57
+ # string are both Strings, and the encoding is what tells them apart:
58
+ # File.binread returns ASCII-8BIT, a base64 string is ordinary text.
59
+ #
60
+ # This reads the label rather than sniffing the contents. Sniffing cannot
61
+ # settle it — an SVG read as text is valid UTF-8 and is not base64, so any
62
+ # rule loose enough to catch it also catches short base64 and encodes it
63
+ # twice, uploading gibberish of exactly the right content type. Reading
64
+ # image files with File.binread keeps both cases unambiguous.
65
+ def raw_bytes?(content)
66
+ content.encoding == Encoding::BINARY || !content.valid_encoding?
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "resource"
4
+
5
+ module Mailtea
6
+ # The +automation_runs+ resource (one contact's journey through one
7
+ # automation). Reach it at <tt>mailtea.automation_runs</tt>.
8
+ #
9
+ # Runs are nested under an automation and scoped to a publication — pass
10
+ # +automation_id+ and +publication_id+. A run PINS the automation version it
11
+ # started on, so #get returns the graph the run is actually executing, not the
12
+ # live one.
13
+ class AutomationRuns < Resource
14
+ # List an automation's runs (cursor-paginated). Filters: +publication_id+
15
+ # (required), +status+ (one or more run statuses — pass an Array and it is
16
+ # joined for you), +contact_id+, +is_test+, +limit+, +after+. List items omit
17
+ # the pinned graph and the step runs; use #get for those.
18
+ def list(automation_id, params = nil, **filters)
19
+ merged = payload(params, filters)
20
+ status = merged["status"]
21
+ merged["status"] = status.join(",") if status.is_a?(Array)
22
+ request("GET", "/v1/automations/" + escape(automation_id) + "/runs" + query(merged))
23
+ end
24
+
25
+ # Retrieve one run in full. Requires +publication_id+. Returns the PINNED
26
+ # +steps+/+connections+, the per-step +step_runs+, and +waiting+
27
+ # (+resume_at+ / +waiting_event_name+) — read this rather than an event
28
+ # ingest's +resumed_runs+ counter to tell whether an event actually advanced
29
+ # the run.
30
+ def get(automation_id, run_id, params = nil, **filters)
31
+ request(
32
+ "GET",
33
+ "/v1/automations/" + escape(automation_id) + "/runs/" + escape(run_id) +
34
+ query(payload(params, filters))
35
+ )
36
+ end
37
+
38
+ # Cancel one in-flight run. Requires +publication_id+. A cancelled run cannot
39
+ # be resumed. Returns the run in full detail.
40
+ def cancel(automation_id, run_id, params = nil, **filters)
41
+ request(
42
+ "POST",
43
+ "/v1/automations/" + escape(automation_id) + "/runs/" + escape(run_id) + "/cancel" +
44
+ query(payload(params, filters))
45
+ )
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,157 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "resource"
4
+
5
+ module Mailtea
6
+ # The +automations+ resource (multi-step contact journeys). Reach it at
7
+ # <tt>mailtea.automations</tt>.
8
+ #
9
+ # Automations are scoped to a publication — pass +publication_id+. An
10
+ # automation is a graph: +steps+ (each <tt>{ key:, type:, label:, config: }</tt>)
11
+ # plus optional +connections+ (each <tt>{ from:, to:, branch: }</tt>).
12
+ #
13
+ # +connections+ is optional: omit it and the server links the steps in array
14
+ # order with <tt>branch: "next"</tt>, rooted at the trigger. A graph containing
15
+ # a +condition+ or +wait_for_event+ step cannot be inferred that way and is
16
+ # rejected with +connections_required_for_branching+ — send its connections
17
+ # explicitly.
18
+ #
19
+ # Failures come back as coded +issues+ rather than schema errors, and for a
20
+ # draft/paused/archived automation they ride along informationally instead of
21
+ # blocking the save.
22
+ class Automations < Resource
23
+ # Dry-run a graph without creating anything. Takes +publication_id+ and
24
+ # +steps+, plus optional +connections+. Returns
25
+ # <tt>{ "object" => "automation_validation", "valid" => ..., "issues" => [...] }</tt>.
26
+ def validate(params = nil, **fields)
27
+ request("POST", "/v1/automations/validate", payload(params, fields))
28
+ end
29
+
30
+ # Create an automation. Takes +publication_id+, +name+ and +steps+, plus
31
+ # optional +description+, +connections+, +reentry_policy+
32
+ # ("once"/"once_per_window"/"always" — "once_per_window" requires
33
+ # +reentry_window_seconds+), +on_step_failure+ and +validate_only+. With
34
+ # <tt>validate_only: true</tt> nothing is written and an
35
+ # +automation_validation+ is returned instead. New automations start as
36
+ # +draft+ — #activate starts them.
37
+ def create(params = nil, **fields)
38
+ request("POST", "/v1/automations", payload(params, fields))
39
+ end
40
+
41
+ # List automations (cursor-paginated). Filters: +publication_id+ (required),
42
+ # +status+ ("draft"/"active"/"paused"/"archived"), +limit+, +after+. List
43
+ # items omit +steps+, +connections+, +valid+ and +issues+ — use #get for the
44
+ # full graph.
45
+ def list(params = nil, **filters)
46
+ request("GET", "/v1/automations" + query(payload(params, filters)))
47
+ end
48
+
49
+ # Retrieve one automation with its live graph and current +issues+.
50
+ # Requires +publication_id+.
51
+ def get(id, params = nil, **filters)
52
+ request("GET", "/v1/automations/" + escape(id) + query(payload(params, filters)))
53
+ end
54
+
55
+ # Update an automation's +name+, +description+, +steps+, +connections+,
56
+ # +reentry_policy+, +reentry_window_seconds+ or +on_step_failure+.
57
+ # +publication_id+ is required and goes in the query string. The graph is
58
+ # replaced wholesale and cuts a new version.
59
+ #
60
+ # <tt>validate_only: true</tt> returns an +automation_validation+ and writes
61
+ # nothing. A graph change that carries errors saves anyway while the
62
+ # automation is draft/paused/archived; on an +active+ one it is a 422 —
63
+ # pause, save, then start again.
64
+ def update(id, params = nil, **fields)
65
+ scope, body = Util.split_publication(payload(params, fields), keep_in_body: false)
66
+ request("PATCH", "/v1/automations/" + escape(id) + scope, body)
67
+ end
68
+
69
+ # Delete an automation. Requires +publication_id+. Deleting an +active+
70
+ # automation is a 409 +automation_active+ — pause or archive it first so its
71
+ # in-flight runs are not dropped silently.
72
+ def delete(id, params = nil, **filters)
73
+ request("DELETE", "/v1/automations/" + escape(id) + query(payload(params, filters)))
74
+ end
75
+
76
+ # Start the automation so new contacts enroll. Requires +publication_id+. A
77
+ # graph with errors is refused with 422 +automation_invalid+ and the
78
+ # blocking +issues+.
79
+ def activate(id, params = nil, **filters)
80
+ request("POST", "/v1/automations/" + escape(id) + "/activate" + query(payload(params, filters)))
81
+ end
82
+
83
+ # Stop new enrollments. Requires +publication_id+ (query). Optional
84
+ # +cancel_runs+ — it *defaults to false* here, so in-flight runs keep going;
85
+ # pass <tt>cancel_runs: true</tt> to exit them. Returns the automation plus
86
+ # +canceled_runs+.
87
+ def pause(id, params = nil, **fields)
88
+ lifecycle(id, "/pause", payload(params, fields))
89
+ end
90
+
91
+ # Archive the automation. Requires +publication_id+ (query). Optional
92
+ # +cancel_runs+ — it *defaults to true* here (the opposite of #pause), so
93
+ # in-flight runs exit with +automation_archived+. Returns the automation plus
94
+ # +canceled_runs+.
95
+ def archive(id, params = nil, **fields)
96
+ lifecycle(id, "/archive", payload(params, fields))
97
+ end
98
+
99
+ # List an automation's versions (cursor-paginated). Filters:
100
+ # +publication_id+ (required), +limit+, +after+. List items carry no
101
+ # +steps+/+connections+ — use #version for a stored graph.
102
+ def versions(id, params = nil, **filters)
103
+ request("GET", "/v1/automations/" + escape(id) + "/versions" + query(payload(params, filters)))
104
+ end
105
+
106
+ # Retrieve one stored version, including its +steps+ and +connections+.
107
+ # Requires +publication_id+. This is the graph a run of that version is
108
+ # pinned to — editing the automation never rewrites it.
109
+ def version(id, version, params = nil, **filters)
110
+ request(
111
+ "GET",
112
+ "/v1/automations/" + escape(id) + "/versions/" + escape(version) +
113
+ query(payload(params, filters))
114
+ )
115
+ end
116
+
117
+ # Per-step funnel counts for an automation. Filters: +publication_id+
118
+ # (required), +version+ (omit to aggregate across ALL versions), +since+,
119
+ # +until+ (ISO 8601). Test runs are always excluded
120
+ # (<tt>excludes_test_runs: true</tt>). Condition steps report
121
+ # <tt>branches: { condition_met, condition_not_met }</tt>, +wait_for_event+
122
+ # steps <tt>{ event_received, timeout }</tt>.
123
+ def metrics(id, params = nil, **filters)
124
+ request("GET", "/v1/automations/" + escape(id) + "/metrics" + query(payload(params, filters)))
125
+ end
126
+
127
+ # Run the automation once against a real contact. +publication_id+ is
128
+ # required and goes in the query string; the body takes one of +contact_id+
129
+ # or +email+, plus optional +event_properties+ to seed the run's +event.*+
130
+ # namespace.
131
+ #
132
+ # A test run *sends real, billed email* to that inbox — it does not bypass
133
+ # any send gate. It is flagged +is_test+ and excluded from #metrics. Returns
134
+ # 202 with the queued run.
135
+ def test(id, params = nil, **fields)
136
+ scope, body = Util.split_publication(payload(params, fields), keep_in_body: false)
137
+ request("POST", "/v1/automations/" + escape(id) + "/test" + scope, body)
138
+ end
139
+
140
+ private
141
+
142
+ # +publication_id+ goes in the query but +cancel_runs+ is read from the body,
143
+ # so the two are split here. No body is sent when the caller omitted
144
+ # +cancel_runs+ — or passed it as +nil+, which the server's schema would
145
+ # reject — so the per-verb default applies in both cases.
146
+ def lifecycle(id, suffix, merged)
147
+ cancel_runs = merged["cancel_runs"]
148
+ body = cancel_runs.nil? ? nil : { "cancel_runs" => cancel_runs }
149
+ request(
150
+ "POST",
151
+ "/v1/automations/" + escape(id) + suffix +
152
+ query({ "publication_id" => merged["publication_id"] }),
153
+ body
154
+ )
155
+ end
156
+ end
157
+ end