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