mailtea 0.3.0 → 0.5.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 98e959c77cb6dc2c7b99cff06807b2c18052d6147bd03ec4b8256f89c88859e9
4
- data.tar.gz: 814086291d3c3937eb1cadeae73500add856b67f4f28ea1105a3ddceb4fceb9b
3
+ metadata.gz: 3539cea90528dd0db53f91ddaf0897c093ce83f6ec75ccac90666ba24fb6dcd9
4
+ data.tar.gz: e1f33edadd5e115d42f3d3294d589034d76bb29e870dbe5c952c06fc11fab42e
5
5
  SHA512:
6
- metadata.gz: b0cc04ddef836710cfc8690c8c4fee4fc400e04603be449d491024eaeac2a5c3b4558d31fd32bd8e509d4ec7e5c07cc1f48160d75b995fb45fca7514c217e807
7
- data.tar.gz: 2fa9199a5dded8c0b80024fe49fe54d82c1bb037cf96fe15d02f4c4ff20ed6d7e2b1064a167c1eaf83c9d83fce065e1e0c047e7602977ec9eff15029df1dac83
6
+ metadata.gz: abc137144ae2f95a8a881005409eeff50142404f8b9ba35043482f2780d80c4e2e887263c1953c16b79f39bbd3ec364c484e8cd95c0541978c1d3746a3878c56
7
+ data.tar.gz: 56f72ee40bb049f99e4d95fc58c78539fa6da22109b25d6bbf7c6af87a08fa9109e7cb33f3bd72a787e6559067f5db97f6382ab00372b9b42ae8866e1db16b14
data/CHANGELOG.md CHANGED
@@ -2,6 +2,98 @@
2
2
 
3
3
  All notable changes to the `mailtea` Ruby gem are documented here.
4
4
 
5
+ ## 0.5.0 (2026-09-28)
6
+
7
+ - Breaking: `Posts#create` with `template_id` now HTML-escapes the `variables`
8
+ you pass, the same as every other send. HTML passed in a `{{key}}` value now
9
+ arrives as visible text, and a value you escaped yourself arrives
10
+ double-escaped. Put `{{{key}}}` in the template where a value is meant to be
11
+ raw HTML. Variables are now filled in both the `{{key}}` and Visual Email
12
+ Designer `{key}` forms. A declared variable you do not pass stays in the
13
+ post with its `fallback_value`, so the broadcast gives each recipient their
14
+ own value or that fallback, and undeclared tokens like
15
+ `{{contact.first_name}}` are left for the broadcast too. The post keeps the
16
+ template's published page style, is wrapped in that page, and has its
17
+ show-if blocks decided per recipient when it is sent. Before, only the
18
+ variables you passed were replaced, raw, and only in `{{key}}` form. It uses
19
+ the template's published version; Mailtea Studio's "Use template" starts
20
+ from the latest saved design instead.
21
+ - Changed: `Templates#update` and `Templates#restore_version` no longer move a
22
+ published template back to draft. The template keeps its published status,
23
+ and automations and the API keep sending its published version until
24
+ `Templates#publish` is called again. The template's `from` and `reply_to`
25
+ are part of the published version too, so a new sender or reply-to address
26
+ reaches sends only after the next publish. `Templates#unpublish` is now the
27
+ only way to stop a published template sending, short of deleting it, and it
28
+ drops the stored published version so the next publish starts from the
29
+ current content.
30
+ - Added: `has_unpublished_versions` on every returned template hash. True only
31
+ when the template is published and its saved content (From, Reply-To and the style profile
32
+ included) differs from the published version.
33
+ - Added: `is_published` on each template version entry: true for the one entry
34
+ automations and the API are sending now. `is_current` is now described as
35
+ what it is: the entry that matches the working copy (the saved design being
36
+ edited), not necessarily what is sending. `is_published` is false on every
37
+ entry of a draft, and on a template published before the field existed until
38
+ it is published again.
39
+ - Changed: the `unpublished` key on the update and restore replies is kept for
40
+ compatibility and is now always `false`. Check `has_unpublished_versions`
41
+ (or the reply's `message`) instead.
42
+ - Changed (API behavior): a template variable's `fallback_value` can no longer
43
+ contain `{` or `}`. Creating a template with one, or changing a fallback
44
+ to one on update, is a 400 ("Fallbacks can't contain { or }."). A value
45
+ the template already stores is accepted unchanged, so a template saved
46
+ before the rule keeps saving. Inline chip fallbacks such as
47
+ `{first_name|Mom & Pop}` now render as written instead of double-escaped.
48
+ - Changed (API behavior): saving an active automation is refused only when the
49
+ edit adds an error the live version does not already have. The 422
50
+ `active_graph_invalid` reply's `issues` lists just those new problems.
51
+ Before, any error refused the save, even one the live version already had.
52
+ Starting refuses every error as before, except an `unknown_step_ref` at a
53
+ `config.*` path or a trigger `missing_branch` that the version the
54
+ automation last ran on already had, so pausing and starting an unchanged
55
+ automation keeps working. Issues the last live version already had come back
56
+ with `pre_existing: true`.
57
+ - Added (API behavior): issue objects carry `field`, what a rule reads (the
58
+ rule's `field`, or the path in a `{"var": ...}` value, e.g.
59
+ `steps.welcome.opened`) when the issue is about one.
60
+ - Changed (API behavior): two issues are the same problem when their code and
61
+ step match, and their `field` or, when there is none, their `path`. Moving
62
+ a rule, by removing a rule beside it or putting it in a group, no longer
63
+ makes a problem the live version already had look new. An error is
64
+ `pre_existing` only if the live version had an error there, not a warning.
65
+ - Changed (API behavior): `validate_only` on an active automation answers the
66
+ way the save would. A trigger change is a 422 `trigger_locked_while_active`,
67
+ a change that adds a problem is a 422 `active_graph_invalid` listing only
68
+ the new problems, and otherwise issues come back with `pre_existing` marked
69
+ against the version live now. Before, it returned every issue unmarked.
70
+ - Changed (API behavior): changing the trigger (its type or key) of an active
71
+ automation is now refused with 422 `trigger_locked_while_active`. Pause it
72
+ first; draft and paused automations can still change their trigger. Before,
73
+ the change was accepted.
74
+ - Added (API behavior): new validation rules. A trigger with nothing after it
75
+ is a `missing_branch` error at `branches.next`. A rule or `{"var": ...}`
76
+ value that reads `steps.<key>.*` for a step that isn't in the automation is
77
+ an `unknown_step_ref` error at that `config.*` path, or a warning when the
78
+ `{"var": ...}` has a `default`. A rule or value that reads
79
+ `event.properties.*` when the automation does not start from an app event is
80
+ the new warning `event_field_without_event_trigger`.
81
+
82
+ ## 0.4.0 (2026-09-15)
83
+
84
+ - Added: test mode. `mailtea.api_keys.create(name: "CI", mode: "test")` mints a
85
+ test key (prefixed `mt_test_`) whose sends are validated, recorded and
86
+ webhook-emitting but never delivered, so CI can run against production Mailtea
87
+ with your real code and your real webhook handler. A test key is **not** a
88
+ data sandbox — it reads and writes your real contacts, templates, senders and
89
+ webhooks. Only delivery is simulated.
90
+ - Added: `mailtea.emails.list(mode: "test")` reads test-mode mail, and every
91
+ email carries `mode`. There is no mixed view: a test key reads only test
92
+ emails and a live key only live ones.
93
+ - Reserved recipients on `test.mailtea.email` force an outcome: `delivered@`,
94
+ `bounced@`, `complained@`, `delayed@`, `failed@`. The first `to` recipient
95
+ decides; anything else is delivered.
96
+
5
97
  ## 0.3.0 (2026-09-10)
6
98
 
7
99
  - Added: `mailtea.domains.update(id, tracking_subdomain: nil)` removes a
data/README.md CHANGED
@@ -118,7 +118,7 @@ mailtea = Mailtea::Client.new(ENV["MAILTEA_API_KEY"], transport: recorder)
118
118
  | `emails.send(params)` | Send a transactional email → `{ "id" => … }` |
119
119
  | `emails.batch(emails)` | Send up to 100 emails → `{ "data" => [{ "id" => … }] }` |
120
120
  | `emails.get(id)` | Retrieve an email and its delivery status |
121
- | `emails.list(params = nil)` | List emails → `{ "data", "total", "limit", "offset", "has_more" }` |
121
+ | `emails.list(params = nil)` | List emails → `{ "data", "total", "limit", "offset", "has_more" }`. Pass `mode: "test"` for test-mode mail |
122
122
  | `emails.update(id, params)` | Reschedule a scheduled email |
123
123
  | `emails.reschedule(id, scheduled_at)` | Convenience wrapper over `update` |
124
124
  | `emails.cancel(id)` | Cancel a scheduled email (`POST /v1/emails/:id/cancel`) |
@@ -139,7 +139,7 @@ mailtea = Mailtea::Client.new(ENV["MAILTEA_API_KEY"], transport: recorder)
139
139
  | `templates.create / list / get / update / publish / unpublish / duplicate / delete` | Manage reusable email templates |
140
140
  | `templates.render(params)` | Render a spec to HTML without saving → `{ "html", "text" }` |
141
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** |
142
+ | `templates.restore_version(id, version, params = nil)` | Put an older design back as unpublished changes; a published template keeps sending its published version until you `publish` again |
143
143
  | `assets.upload / list / delete` | The publication's image library (raw bytes are base64-encoded for you) |
144
144
  | `suppressions.list / add / remove` | Manage the team-wide do-not-send list |
145
145
  | `suppressions.export` | Export the whole suppression list as CSV (raw text) |
@@ -147,7 +147,7 @@ mailtea = Mailtea::Client.new(ENV["MAILTEA_API_KEY"], transport: recorder)
147
147
  | `domains.tracking.create / list / verify / delete` | Manage CNAME tracking sub-domains under a domain |
148
148
  | `webhooks.create / list / get / update / delete` | Manage outbound event subscriptions |
149
149
  | `contact_properties.create / list / update / delete` | Manage custom contact fields (team-scoped) |
150
- | `api_keys.create / list / revoke` | Manage API keys (`settings:write`) |
150
+ | `api_keys.create / list / revoke` | Manage API keys (`settings:write`). `create(mode: "test")` mints a test key |
151
151
  | `automations.create / list / get / update / delete` | Manage automation graphs (`steps` + optional `connections`) |
152
152
  | `automations.validate(params)` | Dry-run a graph → `{ "valid", "issues" }` (no automation needed) |
153
153
  | `automations.activate / pause / archive` | Lifecycle (`cancel_runs` defaults **false** on pause, **true** on archive) |
@@ -167,6 +167,35 @@ take none.
167
167
  on their resource object, because the API's verb is "send". Ruby's `__send__`
168
168
  is untouched, so metaprogramming still works.
169
169
 
170
+ ## Test mode
171
+
172
+ A test key (`mt_test_…`) sends nothing. Every message it creates is validated,
173
+ recorded and emits webhooks, but is never handed to a provider — so CI can point
174
+ at production Mailtea with your real code and your real webhook handler.
175
+
176
+ ```ruby
177
+ key = mailtea.api_keys.create(name: "CI", mode: "test")
178
+ # key["token"] starts with mt_test_
179
+
180
+ test = Mailtea::Client.new(key["token"])
181
+ test.emails.send(
182
+ from: "you@yourdomain.com",
183
+ to: "bounced@test.mailtea.email",
184
+ subject: "Bounce handling",
185
+ html: "<p>Never delivered.</p>"
186
+ )
187
+
188
+ page = test.emails.list(mode: "test")
189
+ ```
190
+
191
+ Reserved recipients on `test.mailtea.email` force the outcome — `delivered@`,
192
+ `bounced@`, `complained@`, `delayed@`, `failed@` — and the first `to` recipient
193
+ decides. Every email carries `mode`. A test key reads only test mail and a live
194
+ key only live mail; there is no mixed view.
195
+
196
+ A test key is **not** a data sandbox. It reads and writes your real contacts,
197
+ templates, senders and webhooks. Only delivery is simulated.
198
+
170
199
  ## Webhooks
171
200
 
172
201
  Mailtea signs every outbound webhook with
@@ -11,7 +11,13 @@ module Mailtea
11
11
  # Create an API key. The +token+ is returned ONCE — store it securely.
12
12
  #
13
13
  # Takes +name+, optional +permission+ ("full_access" or "sending_access"),
14
- # and optional +domain_id+.
14
+ # optional +domain_id+, and optional +mode+.
15
+ #
16
+ # +mode+ is "live" (the default) or "test". A test key is prefixed
17
+ # <tt>mt_test_</tt>: its sends are validated, recorded and emit webhooks but
18
+ # are never delivered, and it reads only test mail. It is NOT a data sandbox
19
+ # — it reads and writes your real contacts, templates, senders and webhooks.
20
+ # Only delivery is simulated.
15
21
  def create(params = nil, **fields)
16
22
  request("POST", "/v1/api-keys", payload(params, fields))
17
23
  end
@@ -59,7 +59,11 @@ module Mailtea
59
59
  #
60
60
  # <tt>validate_only: true</tt> returns an +automation_validation+ and writes
61
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 —
62
+ # automation is draft/paused/archived. On an +active+ one it is a 422
63
+ # +active_graph_invalid+ only when it adds an error the live version does
64
+ # not already have; +issues+ then lists just those new problems, and older
65
+ # ones come back with <tt>pre_existing: true</tt>. Changing an +active+
66
+ # automation's trigger is a 422 +trigger_locked_while_active+. Either way:
63
67
  # pause, save, then start again.
64
68
  def update(id, params = nil, **fields)
65
69
  scope, body = Util.split_publication(payload(params, fields), keep_in_body: false)
@@ -75,7 +79,9 @@ module Mailtea
75
79
 
76
80
  # Start the automation so new contacts enroll. Requires +publication_id+. A
77
81
  # graph with errors is refused with 422 +automation_invalid+ and the
78
- # blocking +issues+.
82
+ # blocking +issues+, except an +unknown_step_ref+ at a <tt>config.*</tt>
83
+ # path or a trigger +missing_branch+ that the version it last ran on
84
+ # already had (<tt>pre_existing: true</tt>).
79
85
  def activate(id, params = nil, **filters)
80
86
  request("POST", "/v1/automations/" + escape(id) + "/activate" + query(payload(params, filters)))
81
87
  end
@@ -90,9 +90,15 @@ module Mailtea
90
90
  email
91
91
  end
92
92
 
93
- # List emails (most recent first). Optional filters: +status+, +tag_name+,
94
- # +tag_value+, +search+ (substring match on recipient/sender/subject),
95
- # +from_date+, +to_date+, +limit+, +offset+.
93
+ # List emails (most recent first). Optional filters: +status+, +mode+,
94
+ # +tag_name+, +tag_value+, +search+ (substring match on
95
+ # recipient/sender/subject), +from_date+, +to_date+, +limit+, +offset+.
96
+ #
97
+ # +mode+ is "live" or "test" — there is no mixed view. A test key reads only
98
+ # test mail and a live key only live mail, so this filter matters to a
99
+ # session-backed credential; asking for the mode your key is not in is an
100
+ # error rather than an empty list. Every returned email carries its own
101
+ # +mode+.
96
102
  #
97
103
  # +from_date+ is clamped to the plan's analytics retention window — 30 days
98
104
  # on most plans, 90 on Scale and Enterprise. A value reaching further back
data/lib/mailtea/posts.rb CHANGED
@@ -7,10 +7,19 @@ module Mailtea
7
7
  # <tt>mailtea.posts</tt>.
8
8
  class Posts < Resource
9
9
  # Create a newsletter post (a draft by default). Seed it from a published
10
- # server template with +template_id+ + +variables+, or pass inline +html+.
11
- # +kind+ selects the post type ("newsletter" or "broadcast"). Set
12
- # <tt>send: true</tt> to deliver right after creating (or with
13
- # +scheduled_at+ to schedule) — that requires the +issues:send+ scope.
10
+ # server template with +template_id+ + +variables+, using the template's
11
+ # PUBLISHED version and not any unpublished edits saved since, or pass
12
+ # inline +html+. The +variables+ you pass are filled in, in both the
13
+ # <tt>{{key}}</tt> and Visual Email Designer <tt>{key}</tt> forms, and
14
+ # HTML-escaped (use <tt>{{{key}}}</tt> in the template for raw HTML).
15
+ # Everything else is left for the broadcast to fill per recipient: a
16
+ # declared variable you do not pass keeps its +fallback_value+ for
17
+ # recipients with no value, and undeclared tokens like
18
+ # <tt>{{contact.first_name}}</tt> stay as they are. The post keeps the
19
+ # template's published page style. +kind+ selects the post type
20
+ # ("newsletter" or "broadcast"). Set <tt>send: true</tt> to deliver right
21
+ # after creating (or with +scheduled_at+ to schedule); that requires the
22
+ # +issues:send+ scope.
14
23
  #
15
24
  # Returns <tt>{ "id" => ... }</tt>.
16
25
  def create(params = nil, publication_id: UNSET, subject: UNSET, html: UNSET,
@@ -46,6 +46,13 @@ module Mailtea
46
46
  # +global_css+, +category+, +preview_image_url+, +tags+, +text+, +subject+,
47
47
  # +from+ and +reply_to+ accept +nil+ to clear them. +publication_id+ is
48
48
  # required and goes in the query string.
49
+ #
50
+ # Editing a published template no longer unpublishes it: the change is
51
+ # saved as the working copy, the template keeps its published status, and
52
+ # the published version keeps sending until #publish is called again. The
53
+ # reply's +unpublished+ is kept for compatibility and is always +false+
54
+ # now; check +has_unpublished_versions+ on the reply instead (it also
55
+ # carries +message+ when that is +true+).
49
56
  def update(id, params = nil, **fields)
50
57
  scope, body = Util.split_publication(payload(params, fields))
51
58
  request("PATCH", "/v1/templates/" + escape(id) + scope, body)
@@ -57,8 +64,11 @@ module Mailtea
57
64
  end
58
65
 
59
66
  # Return a published template to draft. +published_at+ is kept — it records
60
- # that the template was published once, not that it still is. Requires
61
- # +publication_id+.
67
+ # that the template was published once, not that it still is. This is now
68
+ # the only way to stop a published template sending, short of deleting it
69
+ # (editing or restoring it no longer does that on its own). It also drops
70
+ # the published version, so the next #publish starts from the current
71
+ # (working) content. Requires +publication_id+.
62
72
  def unpublish(id, params = nil, **filters)
63
73
  request("POST", "/v1/templates/" + escape(id) + "/unpublish" + query(payload(params, filters)))
64
74
  end
@@ -66,13 +76,18 @@ module Mailtea
66
76
  # List a template's design history, newest first. Requires +publication_id+;
67
77
  # optional +limit+ (the server caps it at the retained maximum).
68
78
  #
69
- # Entries are metadata only — +version+, +origin+ ("edit", "publish" or
79
+ # Entries are metadata only: +version+, +origin+ ("edit", "publish" or
70
80
  # "restore"), +restored_from_version+, +format+, +name+, +sealed+,
71
- # +is_current+, +created_at+, +updated_at+ and +author+ (or nil) — never the
72
- # design document, which one entry alone can carry half a megabyte of.
73
- # +is_current+ marks the design the template is serving right now, which is
74
- # not always the newest entry: a metadata-only update touches the template
75
- # without recording a version.
81
+ # +is_current+, +is_published+, +created_at+, +updated_at+ and +author+ (or
82
+ # nil). The design document is never included, because one entry alone can
83
+ # carry half a megabyte of it. +is_current+ marks the entry that matches the working copy
84
+ # (the saved design being edited), which is not always the newest entry: a
85
+ # metadata-only update touches the template without recording a version.
86
+ # +is_published+ (a boolean) marks the entry automations and the API are
87
+ # sending now. They differ while a published template has unpublished
88
+ # changes. +is_published+ is +false+ on every entry of a draft, and on every
89
+ # entry of a template published before the field existed until it is
90
+ # published again.
76
91
  #
77
92
  # The reply also carries +retention+: only the newest +max_versions+ are
78
93
  # kept, and consecutive edits by the same author within
@@ -84,10 +99,13 @@ module Mailtea
84
99
  # Put an older design from #versions back onto the template. Requires
85
100
  # +publication_id+.
86
101
  #
87
- # *Restoring is a content write, so the template returns to draft* —
88
- # automations and the API stop sending it until #publish is called again.
89
- # The reply's +unpublished+ reports whether that just happened; re-publishing
90
- # is the caller's job.
102
+ # *Restoring no longer unpublishes the template.* It is a content write,
103
+ # and lands in the working copy: a published template keeps its published
104
+ # status and keeps sending its published version until #publish makes the
105
+ # restored design live. The reply's +unpublished+ is kept for
106
+ # compatibility and is always +false+ now; check +has_unpublished_versions+
107
+ # on the returned +template+ (or the reply's +message+) to see whether the
108
+ # restored design is live yet.
91
109
  #
92
110
  # History is forward-only: the design being replaced is recorded as its own
93
111
  # version first, then the restored design is appended as the new newest one.
@@ -96,10 +114,10 @@ module Mailtea
96
114
  #
97
115
  # Restoring the design that is already current writes nothing and returns
98
116
  # <tt>restored: false</tt> with <tt>reason: "identical"</tt> and
99
- # <tt>unpublished: false</tt>, so a no-op restore cannot unpublish a live
100
- # template. A version that has aged out of retention raises Mailtea::Error
101
- # with +code+ "template_version_not_found". Returns +restored+,
102
- # +restored_from_version+, +unpublished+, +message+ and the updated +template+.
117
+ # <tt>unpublished: false</tt>. A version that has aged out of retention
118
+ # raises Mailtea::Error with +code+ "template_version_not_found". Returns
119
+ # +restored+, +restored_from_version+, +unpublished+, +message+ and the
120
+ # updated +template+.
103
121
  def restore_version(id, version, params = nil, **filters)
104
122
  request(
105
123
  "POST",
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Mailtea
4
- VERSION = "0.3.0"
4
+ VERSION = "0.5.0"
5
5
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: mailtea
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.0
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mailtea
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-09-10 00:00:00.000000000 Z
11
+ date: 2026-09-28 00:00:00.000000000 Z
12
12
  dependencies: []
13
13
  description: 'The official Ruby SDK for Mailtea: a thin, zero-dependency wrapper over
14
14
  the Mailtea REST API, built on net/http and json.'