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 +7 -0
- data/CHANGELOG.md +67 -0
- data/LICENSE +21 -0
- data/README.md +234 -0
- data/lib/mailtea/api_keys.rb +29 -0
- data/lib/mailtea/assets.rb +69 -0
- data/lib/mailtea/automation_runs.rb +48 -0
- data/lib/mailtea/automations.rb +157 -0
- data/lib/mailtea/client.rb +183 -0
- data/lib/mailtea/contact_properties.rb +30 -0
- data/lib/mailtea/contacts.rb +55 -0
- data/lib/mailtea/domains.rb +151 -0
- data/lib/mailtea/emails.rb +135 -0
- data/lib/mailtea/error.rb +44 -0
- data/lib/mailtea/events.rb +86 -0
- data/lib/mailtea/inbound.rb +66 -0
- data/lib/mailtea/posts.rb +77 -0
- data/lib/mailtea/resource.rb +34 -0
- data/lib/mailtea/response.rb +62 -0
- data/lib/mailtea/segments.rb +40 -0
- data/lib/mailtea/senders.rb +40 -0
- data/lib/mailtea/suppressions.rb +37 -0
- data/lib/mailtea/templates.rb +121 -0
- data/lib/mailtea/topics.rb +49 -0
- data/lib/mailtea/transport.rb +116 -0
- data/lib/mailtea/util.rb +77 -0
- data/lib/mailtea/version.rb +5 -0
- data/lib/mailtea/webhook_signing.rb +113 -0
- data/lib/mailtea/webhooks.rb +42 -0
- data/lib/mailtea.rb +25 -0
- metadata +78 -0
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
|