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.
@@ -0,0 +1,183 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ require_relative "api_keys"
6
+ require_relative "assets"
7
+ require_relative "automation_runs"
8
+ require_relative "automations"
9
+ require_relative "contact_properties"
10
+ require_relative "contacts"
11
+ require_relative "domains"
12
+ require_relative "emails"
13
+ require_relative "error"
14
+ require_relative "events"
15
+ require_relative "posts"
16
+ require_relative "response"
17
+ require_relative "segments"
18
+ require_relative "senders"
19
+ require_relative "suppressions"
20
+ require_relative "templates"
21
+ require_relative "topics"
22
+ require_relative "transport"
23
+ require_relative "version"
24
+ require_relative "webhooks"
25
+
26
+ module Mailtea
27
+ DEFAULT_BASE_URL = "https://api.mailtea.app"
28
+
29
+ # The Mailtea client.
30
+ #
31
+ # require "mailtea"
32
+ #
33
+ # mailtea = Mailtea::Client.new # reads MAILTEA_API_KEY
34
+ # sent = mailtea.emails.send(
35
+ # from: "you@yourdomain.com",
36
+ # to: "recipient@example.com",
37
+ # subject: "Hello",
38
+ # html: "<p>Sent with Mailtea.</p>"
39
+ # )
40
+ # sent["id"]
41
+ #
42
+ # The API key may be passed explicitly or read from +MAILTEA_API_KEY+.
43
+ # Self-hosting or local dev: pass +base_url+ or set +MAILTEA_API_BASE_URL+.
44
+ class Client
45
+ # Transactional email: send, batch, status, scheduling, inbound.
46
+ attr_reader :emails
47
+ # Audience contacts.
48
+ attr_reader :contacts
49
+ # Newsletter posts/issues.
50
+ attr_reader :posts
51
+ # Audience segments.
52
+ attr_reader :segments
53
+ # Named From identities.
54
+ attr_reader :senders
55
+ # The publication's image library.
56
+ attr_reader :assets
57
+ # The team-wide do-not-send list.
58
+ attr_reader :suppressions
59
+ # Topic definitions.
60
+ attr_reader :topics
61
+ # Reusable server-side email templates.
62
+ attr_reader :templates
63
+ # Sending domains, their tracking sub-domains, and claims on domains held by other teams.
64
+ attr_reader :domains
65
+ # Outbound event subscriptions.
66
+ attr_reader :webhooks
67
+ # Custom contact fields (team-scoped).
68
+ attr_reader :contact_properties
69
+ # API keys.
70
+ attr_reader :api_keys
71
+ # Multi-step contact journeys.
72
+ attr_reader :automations
73
+ # One contact's journey through one automation.
74
+ attr_reader :automation_runs
75
+ # Custom product events.
76
+ attr_reader :events
77
+ # The catalog of event names a publication expects.
78
+ attr_reader :event_definitions
79
+
80
+ # The base URL every request is built on, with any trailing slash removed.
81
+ attr_reader :base_url
82
+
83
+ def initialize(api_key = nil, base_url: nil, transport: nil)
84
+ key = api_key || ENV["MAILTEA_API_KEY"]
85
+ if key.nil? || key.empty?
86
+ raise Error.new(
87
+ "Missing Mailtea API key. Pass it to Mailtea::Client.new(api_key) or set " \
88
+ "the MAILTEA_API_KEY environment variable.",
89
+ code: "missing_api_key"
90
+ )
91
+ end
92
+
93
+ @api_key = key
94
+ configured = base_url || ENV["MAILTEA_API_BASE_URL"]
95
+ configured = nil if configured.nil? || configured.empty?
96
+ @base_url = (configured || DEFAULT_BASE_URL).chomp("/")
97
+ @transport = transport || Transport
98
+
99
+ requester = method(:perform)
100
+ @emails = Emails.new(requester)
101
+ @contacts = Contacts.new(requester)
102
+ @posts = Posts.new(requester)
103
+ @segments = Segments.new(requester)
104
+ @senders = Senders.new(requester)
105
+ @assets = Assets.new(requester)
106
+ @suppressions = Suppressions.new(requester)
107
+ @topics = Topics.new(requester)
108
+ @templates = Templates.new(requester)
109
+ @domains = Domains.new(requester)
110
+ @webhooks = Webhooks.new(requester)
111
+ @contact_properties = ContactProperties.new(requester)
112
+ @api_keys = ApiKeys.new(requester)
113
+ @automations = Automations.new(requester)
114
+ @automation_runs = AutomationRuns.new(requester)
115
+ @events = Events.new(requester)
116
+ @event_definitions = EventDefinitions.new(requester)
117
+ end
118
+
119
+ # Keeps the key out of `p client` and out of any exception report that
120
+ # inspects the object.
121
+ def inspect
122
+ "#<Mailtea::Client base_url=#{@base_url.inspect}>"
123
+ end
124
+
125
+ private
126
+
127
+ def perform(method, path, body = nil, raw: false)
128
+ headers = {
129
+ "Authorization" => "Bearer #{@api_key}",
130
+ "Accept" => "application/json",
131
+ "User-Agent" => "mailtea-ruby/#{VERSION}"
132
+ }
133
+ encoded = nil
134
+ unless body.nil?
135
+ headers["Content-Type"] = "application/json"
136
+ encoded = JSON.generate(body)
137
+ end
138
+
139
+ response = @transport.call(method, @base_url + path, headers, encoded)
140
+ request_id = response.headers["x-request-id"]
141
+
142
+ raise error_from(response, request_id) if response.status >= 400
143
+
144
+ return nil if response.status == 204 || response.body.to_s.empty?
145
+ # A few endpoints (suppressions export) answer with something other than
146
+ # JSON — hand back the body untouched instead of parsing it.
147
+ return response.body if raw
148
+
149
+ parse(response.body)
150
+ end
151
+
152
+ def error_from(response, request_id)
153
+ message = "HTTP #{response.status}"
154
+ code = nil
155
+ details = nil
156
+
157
+ parsed = begin
158
+ JSON.parse(response.body.to_s)
159
+ rescue JSON::ParserError
160
+ nil # non-JSON body — keep the status-line message
161
+ end
162
+
163
+ if parsed.is_a?(Hash)
164
+ message = parsed["error"] if parsed["error"].is_a?(String) && !parsed["error"].empty?
165
+ details = parsed["details"]
166
+ # Machine-readable code from the API, when it sends one (e.g.
167
+ # "marketing_plan_required" on 402). Branching on code survives a copy
168
+ # change to the message.
169
+ code = parsed["code"] if parsed["code"].is_a?(String)
170
+ end
171
+
172
+ Error.new(message, status: response.status, code: code, details: details,
173
+ request_id: request_id)
174
+ end
175
+
176
+ def parse(text)
177
+ JSON.parse(text, object_class: Response)
178
+ rescue JSON::ParserError => e
179
+ raise Error.new("Could not parse the Mailtea API response as JSON: #{e.message}",
180
+ code: "invalid_response")
181
+ end
182
+ end
183
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "resource"
4
+
5
+ module Mailtea
6
+ # The +contact_properties+ resource (custom contact fields). Reach it at
7
+ # <tt>mailtea.contact_properties</tt>.
8
+ #
9
+ # Definitions are team-scoped — there is no +publication_id+. #create takes
10
+ # +key+ and +type+ ("string" or "number").
11
+ class ContactProperties < Resource
12
+ def create(params = nil, **fields)
13
+ request("POST", "/v1/contact-properties", payload(params, fields))
14
+ end
15
+
16
+ def list(params = nil, **filters)
17
+ request("GET", "/v1/contact-properties" + query(payload(params, filters)))
18
+ end
19
+
20
+ # Update a definition's +description+ or +fallback_value+ (+nil+ clears the
21
+ # fallback). The +key+ and +type+ are immutable.
22
+ def update(id, params = nil, **fields)
23
+ request("PATCH", "/v1/contact-properties/" + escape(id), payload(params, fields))
24
+ end
25
+
26
+ def delete(id)
27
+ request("DELETE", "/v1/contact-properties/" + escape(id))
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "resource"
4
+
5
+ module Mailtea
6
+ # The +contacts+ resource. Reach it at <tt>mailtea.contacts</tt>.
7
+ #
8
+ # Audience resources are scoped to a publication — pass +publication_id+.
9
+ class Contacts < Resource
10
+ # Create a contact — or update it if the email already exists in the
11
+ # publication (the endpoint upserts). #upsert is the same call.
12
+ #
13
+ # Takes +publication_id+ and +email+, plus optional +status+
14
+ # ("active"/"unsubscribed"/"suppressed").
15
+ def create(params = nil, publication_id: UNSET, email: UNSET, status: UNSET, **rest)
16
+ body = payload(
17
+ params,
18
+ { publication_id: publication_id, email: email, status: status },
19
+ rest
20
+ )
21
+ request("POST", "/v1/contacts", body)
22
+ end
23
+
24
+ # Create the contact or update it in place — an alias of #create, named for
25
+ # what <tt>POST /v1/contacts</tt> actually does.
26
+ def upsert(params = nil, **fields)
27
+ create(params, **fields)
28
+ end
29
+
30
+ # List contacts (cursor-paginated). Filters: +publication_id+ (required),
31
+ # +status+ ("active"/"unsubscribed"/"suppressed"), +search+ (matches the
32
+ # email address), +limit+, +after+ (cursor from a previous +next_cursor+).
33
+ def list(params = nil, **filters)
34
+ request("GET", "/v1/contacts" + query(payload(params, filters)))
35
+ end
36
+
37
+ # Retrieve a contact by id or by email address. Requires +publication_id+.
38
+ def get(id_or_email, params = nil, **filters)
39
+ request("GET", "/v1/contacts/" + escape(id_or_email) + query(payload(params, filters)))
40
+ end
41
+
42
+ # Update a contact by id or email — currently its +status+.
43
+ # +publication_id+ is required and goes in the query string.
44
+ def update(id_or_email, params = nil, publication_id: UNSET, status: UNSET, **rest)
45
+ merged = payload(params, { publication_id: publication_id, status: status }, rest)
46
+ scope, body = Util.split_publication(merged)
47
+ request("PATCH", "/v1/contacts/" + escape(id_or_email) + scope, body)
48
+ end
49
+
50
+ # Delete a contact by id or email. Requires +publication_id+.
51
+ def delete(id_or_email, params = nil, **filters)
52
+ request("DELETE", "/v1/contacts/" + escape(id_or_email) + query(payload(params, filters)))
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,151 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "resource"
4
+
5
+ module Mailtea
6
+ # Tracking sub-domains (CNAME) under a domain — used to serve open-pixel and
7
+ # click-tracking links from your own domain. Reach it at
8
+ # <tt>mailtea.domains.tracking</tt>.
9
+ class TrackingDomains < Resource
10
+ # Add a tracking sub-domain. Takes +publication_id+ and +subdomain+. The
11
+ # response +records+ lists the CNAME to add.
12
+ def create(domain_id, params = nil, **fields)
13
+ merged = payload(params, fields)
14
+ request(
15
+ "POST",
16
+ "/v1/domains/" + escape(domain_id) + "/tracking-domains" +
17
+ query({ "publication_id" => merged["publication_id"] }),
18
+ { "subdomain" => merged["subdomain"] }
19
+ )
20
+ end
21
+
22
+ # List a domain's tracking sub-domains. Requires +publication_id+.
23
+ def list(domain_id, params = nil, **filters)
24
+ request(
25
+ "GET",
26
+ "/v1/domains/" + escape(domain_id) + "/tracking-domains" + query(payload(params, filters))
27
+ )
28
+ end
29
+
30
+ # Verify a tracking sub-domain's CNAME. Requires +publication_id+.
31
+ def verify(domain_id, tracking_domain_id, params = nil, **filters)
32
+ request(
33
+ "POST",
34
+ "/v1/domains/" + escape(domain_id) + "/tracking-domains/" +
35
+ escape(tracking_domain_id) + "/verify" + query(payload(params, filters))
36
+ )
37
+ end
38
+
39
+ # Delete a tracking sub-domain. Requires +publication_id+.
40
+ def delete(domain_id, tracking_domain_id, params = nil, **filters)
41
+ request(
42
+ "DELETE",
43
+ "/v1/domains/" + escape(domain_id) + "/tracking-domains/" +
44
+ escape(tracking_domain_id) + query(payload(params, filters))
45
+ )
46
+ end
47
+ end
48
+
49
+ # Domain claims — take a domain back from the team that currently holds it.
50
+ # Reach it at <tt>mailtea.domains.claims</tt>.
51
+ #
52
+ # Use this when adding a domain is refused because the host is connected to
53
+ # another publication: open a claim, publish the TXT record the response
54
+ # lists to prove you control the DNS, then #verify it. On success the other
55
+ # team's domain is released and a fresh one is created for you.
56
+ class DomainClaims < Resource
57
+ # Open a claim. Takes +publication_id+, +name+ and an optional +region+.
58
+ # The response +records+ lists the TXT record to publish.
59
+ def create(params = nil, **fields)
60
+ request("POST", "/v1/domains/claim", payload(params, fields))
61
+ end
62
+
63
+ # Poll a claim. Requires +publication_id+.
64
+ def get(id, params = nil, **filters)
65
+ request("GET", "/v1/domains/claims/" + escape(id) + query(payload(params, filters)))
66
+ end
67
+
68
+ # Check the TXT record and complete the claim if it is there.
69
+ #
70
+ # Safe to call repeatedly: a record that has not propagated yet leaves the
71
+ # claim pending with the same record, so nothing has to be republished. A
72
+ # completed claim answers with the fresh +domain+ beside the claim.
73
+ def verify(id, params = nil, **filters)
74
+ request("POST",
75
+ "/v1/domains/claims/" + escape(id) + "/verify" + query(payload(params, filters)))
76
+ end
77
+
78
+ # Withdraw a pending claim. Requires +publication_id+.
79
+ def cancel(id, params = nil, **filters)
80
+ request("DELETE", "/v1/domains/claims/" + escape(id) + query(payload(params, filters)))
81
+ end
82
+ end
83
+
84
+ # The +domains+ resource (email/site sending domains). Reach it at
85
+ # <tt>mailtea.domains</tt>.
86
+ #
87
+ # Scoped to a publication — pass +publication_id+. Register a domain, add the
88
+ # returned DNS +records+, then #verify it before sending from it.
89
+ #
90
+ # #create takes +region+ (fixed at creation), +tls+ and +tracking_subdomain+;
91
+ # #list filters on +region+ and +status+.
92
+ #
93
+ # <tt>update(id, tracking_subdomain: nil)</tt> removes a tracking subdomain:
94
+ # the domain's links go back to being served from the Mailtea host, and links
95
+ # in mail already sent point at the old hostname and stop resolving. The +nil+
96
+ # reaches the wire as an explicit +null+, so omitting the key (leave the
97
+ # subdomain alone) and passing +nil+ (remove it) are different requests. An
98
+ # empty string is neither; it is refused with +tracking_subdomain_invalid+.
99
+ # +nil+ is an update-only value: a create has nothing to clear.
100
+ class Domains < Resource
101
+ # Tracking sub-domains (CNAME) under a domain.
102
+ attr_reader :tracking
103
+
104
+ # Domain claims — take a domain back from another publication.
105
+ attr_reader :claims
106
+
107
+ def initialize(request)
108
+ super
109
+ @tracking = TrackingDomains.new(request)
110
+ @claims = DomainClaims.new(request)
111
+ end
112
+
113
+ # Register a domain. The response +records+ lists the DNS records to add.
114
+ def create(params = nil, **fields)
115
+ request("POST", "/v1/domains", payload(params, fields))
116
+ end
117
+
118
+ # List domains. Requires +publication_id+.
119
+ def list(params = nil, **filters)
120
+ request("GET", "/v1/domains" + query(payload(params, filters)))
121
+ end
122
+
123
+ # Retrieve a domain with its DNS records and status. Requires +publication_id+.
124
+ def get(id, params = nil, **filters)
125
+ request("GET", "/v1/domains/" + escape(id) + query(payload(params, filters)))
126
+ end
127
+
128
+ # Verify a domain via its DNS records; +status+ becomes "verified".
129
+ def verify(id, params = nil, **filters)
130
+ request("POST", "/v1/domains/" + escape(id) + "/verify" + query(payload(params, filters)))
131
+ end
132
+
133
+ # Update a domain — including +custom_return_path+, which delegates a
134
+ # subdomain as the envelope sender so SPF aligns with your own domain. Mail
135
+ # keeps sending on the default return-path until the delegated DNS resolves.
136
+ # +publication_id+ is required and goes in the query string.
137
+ #
138
+ # <tt>tracking_subdomain: nil</tt> removes the tracking subdomain; leaving
139
+ # the key out leaves it alone. Only the query string drops nils, so the
140
+ # removal travels in the body as an explicit +null+.
141
+ def update(id, params = nil, **fields)
142
+ scope, body = Util.split_publication(payload(params, fields))
143
+ request("PATCH", "/v1/domains/" + escape(id) + scope, body)
144
+ end
145
+
146
+ # Delete a domain. Requires +publication_id+.
147
+ def delete(id, params = nil, **filters)
148
+ request("DELETE", "/v1/domains/" + escape(id) + query(payload(params, filters)))
149
+ end
150
+ end
151
+ end
@@ -0,0 +1,135 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "resource"
4
+ require_relative "inbound"
5
+
6
+ module Mailtea
7
+ # The +emails+ resource: transactional send, status, scheduling, and the
8
+ # inbound sub-resource. Reach it at <tt>mailtea.emails</tt>.
9
+ #
10
+ # The documented fields are named keywords; anything else the API accepts can
11
+ # be passed as an extra keyword or inside a leading wire-format Hash, and both
12
+ # styles can be mixed:
13
+ #
14
+ # mailtea.emails.send(from: "a@b.co", to: "c@d.co", subject: "Hi", html: "<p>Hi</p>")
15
+ # mailtea.emails.send({ "from" => "a@b.co", "to" => "c@d.co", "subject" => "Hi" })
16
+ class Emails < Resource
17
+ # Inbound (received) emails: list, get, reply, and attachments.
18
+ attr_reader :inbound
19
+
20
+ def initialize(request)
21
+ super
22
+ @inbound = InboundEmails.new(request)
23
+ end
24
+
25
+ # Send a transactional email. Provide +html+/+text+ OR a +template+.
26
+ # Returns <tt>{ "id" => ... }</tt>.
27
+ #
28
+ # Set the From with exactly one of +from+ (a <tt>Name <email></tt> string) or
29
+ # +sender_id+ (the id of a named, verified publication sender, which also
30
+ # supplies its default +reply_to+).
31
+ #
32
+ # +to+, +cc+, +bcc+ and +reply_to+ each take a single address or an Array.
33
+ # The API caps a message at **50 recipients combined** across +to+, +cc+ and
34
+ # +bcc+.
35
+ #
36
+ # - +tags+ — an Array of <tt>{ name:, value: }</tt> for filtering and analytics.
37
+ # - +headers+ — a Hash of extra custom email headers.
38
+ # - +attachments+ — an Array of <tt>{ filename:, content: }</tt> where
39
+ # +content+ is base64. Add +content_type+ and a +content_id+ to embed an
40
+ # inline image referenced by <tt>cid:</tt> in the HTML; omit +content_id+
41
+ # for a regular file attachment.
42
+ # - +scheduled_at+ — an ISO 8601 datetime to schedule the send.
43
+ # - +tracking_open+ / +tracking_click+ — opt this message out of the open
44
+ # pixel or link rewriting. A sending domain with tracking off cannot be
45
+ # overridden from a send.
46
+ #
47
+ # This deliberately shadows Object#send on the resource object, because the
48
+ # API's verb is "send" and reading <tt>mailtea.emails.send(...)</tt> matters
49
+ # more than metaprogramming on a resource. Ruby's +__send__+ is untouched.
50
+ def send(params = nil, to: UNSET, subject: UNSET, from: UNSET, sender_id: UNSET,
51
+ html: UNSET, text: UNSET, template: UNSET, cc: UNSET, bcc: UNSET,
52
+ reply_to: UNSET, tags: UNSET, headers: UNSET, attachments: UNSET,
53
+ scheduled_at: UNSET, tracking_open: UNSET, tracking_click: UNSET, **rest)
54
+ body = payload(
55
+ params,
56
+ { to: to, subject: subject, from: from, sender_id: sender_id, html: html,
57
+ text: text, template: template, cc: cc, bcc: bcc, reply_to: reply_to,
58
+ tags: tags, headers: headers, attachments: attachments,
59
+ scheduled_at: scheduled_at, tracking_open: tracking_open,
60
+ tracking_click: tracking_click },
61
+ rest
62
+ )
63
+ request("POST", "/v1/emails", body)
64
+ end
65
+
66
+ # Send up to 100 emails in one request. Takes an Array of send payloads and
67
+ # returns <tt>{ "data" => [{ "id" => ... }] }</tt>.
68
+ #
69
+ # The array goes out as the request body verbatim, so each item takes the
70
+ # same fields as #send except +scheduled_at+ and +attachments+.
71
+ def batch(emails)
72
+ unless emails.is_a?(Array)
73
+ raise Error.new(
74
+ "emails.batch takes an Array of email payloads, got #{emails.class}.",
75
+ code: "invalid_batch"
76
+ )
77
+ end
78
+
79
+ request("POST", "/v1/emails/batch", emails.map { |email| payload(email) })
80
+ end
81
+
82
+ # Retrieve an email with its delivery status and tracking counters.
83
+ #
84
+ # Adds a friendly +status+ alias of the raw +last_event+ wire field:
85
+ # "queued", "scheduled", "sent", "delivered", "bounced", "complained",
86
+ # "failed", "delivery_delayed", "suppressed" or "canceled".
87
+ def get(id)
88
+ email = request("GET", "/v1/emails/" + escape(id))
89
+ email["status"] = email["last_event"] if email.is_a?(Hash) && email["status"].nil?
90
+ email
91
+ end
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+.
96
+ #
97
+ # +from_date+ is clamped to the plan's analytics retention window — 30 days
98
+ # on most plans, 90 on Scale and Enterprise. A value reaching further back
99
+ # returns data from the start of that window rather than an error, and
100
+ # omitting it returns the window rather than all time.
101
+ def list(params = nil, **filters)
102
+ request("GET", "/v1/emails" + query(payload(params, filters)))
103
+ end
104
+
105
+ # Aggregate transactional metrics over an optional date window: totals,
106
+ # delivered/bounced/open/click counts, per-status counts, and rates.
107
+ # Optional filters: +from_date+, +to_date+ (ISO 8601).
108
+ #
109
+ # +from_date+ is clamped the same way as #list, and the +from_date+ in the
110
+ # response reports the window actually used.
111
+ def analytics(params = nil, **filters)
112
+ request("GET", "/v1/emails/analytics" + query(payload(params, filters)))
113
+ end
114
+
115
+ # Update a scheduled email (currently only +scheduled_at+).
116
+ def update(id, params = nil, scheduled_at: UNSET, **rest)
117
+ body = payload(params, { scheduled_at: scheduled_at }, rest)
118
+ request("PATCH", "/v1/emails/" + escape(id), body)
119
+ end
120
+
121
+ # Convenience wrapper over #update for the reschedule case.
122
+ def reschedule(id, scheduled_at)
123
+ update(id, scheduled_at: scheduled_at)
124
+ end
125
+
126
+ # Cancel a scheduled email before it sends.
127
+ #
128
+ # Only a message still sitting at +last_event+ "scheduled" can be cancelled:
129
+ # a send with no +scheduled_at+ is already on its way and answers 422, and so
130
+ # does a scheduled one once its time has passed.
131
+ def cancel(id)
132
+ request("POST", "/v1/emails/" + escape(id) + "/cancel")
133
+ end
134
+ end
135
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mailtea
4
+ # Raised when the Mailtea API returns an error, or when the client cannot get
5
+ # a request out of the process at all.
6
+ #
7
+ # One class covers both so a caller needs one +rescue+ on the send path:
8
+ # a 422 from the API and a dropped TCP connection are equally a failed send.
9
+ # +status+ tells them apart — it is the HTTP status for an API error and
10
+ # +0+ for a client-side fault (missing key, no transport, unreachable host).
11
+ #
12
+ # begin
13
+ # mailtea.emails.send(from: from, to: to, subject: "Hi", html: "<p>Hi</p>")
14
+ # rescue Mailtea::Error => e
15
+ # warn e.message # the API's own message, e.g. "Domain not verified"
16
+ # e.status # 422
17
+ # e.code # "marketing_plan_required", when the API sends one
18
+ # e.details # validation issues, when the API sends them
19
+ # e.request_id # the x-request-id header — quote it in support tickets
20
+ # end
21
+ class Error < StandardError
22
+ # HTTP status code, or 0 for a client-side error.
23
+ attr_reader :status
24
+
25
+ # Machine-readable code, when the API sends one (e.g. +marketing_plan_required+)
26
+ # or for a client-side fault (+missing_api_key+, +connection_error+). May be nil.
27
+ # Branching on this survives a copy change to the message.
28
+ attr_reader :code
29
+
30
+ # The API's +details+ array (validation issues), when present. May be nil.
31
+ attr_reader :details
32
+
33
+ # The API's +x-request-id+ header, for support and debugging. May be nil.
34
+ attr_reader :request_id
35
+
36
+ def initialize(message, status: 0, code: nil, details: nil, request_id: nil)
37
+ super(message)
38
+ @status = status
39
+ @code = code
40
+ @details = details
41
+ @request_id = request_id
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "resource"
4
+
5
+ module Mailtea
6
+ # The +events+ resource (custom product events that trigger automations and
7
+ # resume +wait_for_event+ steps). Reach it at <tt>mailtea.events</tt>.
8
+ #
9
+ # Events are scoped to a publication — pass +publication_id+.
10
+ class Events < Resource
11
+ # Record an event for a contact. Takes +publication_id+, +name+, and exactly
12
+ # one of +contact_id+ or +email+ (both is a 400 +contact_reference_conflict+,
13
+ # neither a 400 +contact_reference_required+). Optional: +create_contact+,
14
+ # +properties+, +occurred_at+, +idempotency_key+.
15
+ #
16
+ # +create_contact+ is *opt-in* — without it an unresolvable address is a 404
17
+ # +contact_not_found+ rather than a new contact.
18
+ #
19
+ # Returns 202 with +enrolled_automations+ and +resumed_runs+. A replay of the
20
+ # same +idempotency_key+ returns the ORIGINAL event id with
21
+ # <tt>replayed: true</tt> and always reports <tt>enrolled_automations: 0,
22
+ # resumed_runs: 0</tt>. Note that <tt>resumed_runs: 0</tt> on a FRESH ingest
23
+ # does not prove nothing matched — a run being advanced concurrently is
24
+ # invisible for that instant, so read the run itself
25
+ # (Mailtea::AutomationRuns#get) rather than the counter.
26
+ #
27
+ # Like Emails#send this shadows Object#send on the resource object;
28
+ # +__send__+ is untouched.
29
+ def send(params = nil, **fields)
30
+ request("POST", "/v1/events", payload(params, fields))
31
+ end
32
+
33
+ # List recorded events (cursor-paginated). Filters: +publication_id+
34
+ # (required), +name+, +contact_id+, +limit+, +after+ (cursor from a previous
35
+ # +next_cursor+).
36
+ def list(params = nil, **filters)
37
+ request("GET", "/v1/events" + query(payload(params, filters)))
38
+ end
39
+ end
40
+
41
+ # The +event_definitions+ resource (the catalog of event names a publication
42
+ # expects, with optional property schemas). Reach it at
43
+ # <tt>mailtea.event_definitions</tt>.
44
+ #
45
+ # Definitions are scoped to a publication — pass +publication_id+. They are
46
+ # documentation and tooling, not a gate: Events#send accepts an event with no
47
+ # definition.
48
+ class EventDefinitions < Resource
49
+ # Create an event definition. Takes +publication_id+ and +name+, plus
50
+ # optional +description+ and +schema_json+. The name is immutable once created.
51
+ def create(params = nil, **fields)
52
+ request("POST", "/v1/event-definitions", payload(params, fields))
53
+ end
54
+
55
+ # List event definitions (cursor-paginated). Filters: +publication_id+
56
+ # (required), +limit+, +after+. List items carry no +inferred_properties+ —
57
+ # use #get for those.
58
+ def list(params = nil, **filters)
59
+ request("GET", "/v1/event-definitions" + query(payload(params, filters)))
60
+ end
61
+
62
+ # Retrieve one definition. Requires +publication_id+. Adds
63
+ # +schema_properties+ and +inferred_properties+ — the latter computed on read
64
+ # over the last 500 events, reporting each key's type, sample count and
65
+ # *coverage*. Low coverage is the trap: a condition on a key present in 3% of
66
+ # events will almost never match.
67
+ def get(id, params = nil, **filters)
68
+ request("GET", "/v1/event-definitions/" + escape(id) + query(payload(params, filters)))
69
+ end
70
+
71
+ # Update a definition's +description+ or +schema_json+ (+nil+ clears the
72
+ # schema back to free-form). +publication_id+ is required and goes in the
73
+ # query string. +name+ is immutable — sending it is a 400
74
+ # +event_name_immutable+, not a silently dropped rename.
75
+ def update(id, params = nil, **fields)
76
+ scope, body = Util.split_publication(payload(params, fields), keep_in_body: false)
77
+ request("PATCH", "/v1/event-definitions/" + escape(id) + scope, body)
78
+ end
79
+
80
+ # Delete an event definition. Requires +publication_id+. Events already
81
+ # recorded under that name are untouched.
82
+ def delete(id, params = nil, **filters)
83
+ request("DELETE", "/v1/event-definitions/" + escape(id) + query(payload(params, filters)))
84
+ end
85
+ end
86
+ end