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,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "resource"
|
|
4
|
+
|
|
5
|
+
module Mailtea
|
|
6
|
+
# Attachments on a received email. Reach it at
|
|
7
|
+
# <tt>mailtea.emails.inbound.attachments</tt>.
|
|
8
|
+
#
|
|
9
|
+
# Each returned object carries a short-lived signed +download_url+.
|
|
10
|
+
class InboundAttachments < Resource
|
|
11
|
+
BASE = "/v1/emails/inbound"
|
|
12
|
+
|
|
13
|
+
# List an inbound email's attachments, each with a signed download URL.
|
|
14
|
+
def list(id)
|
|
15
|
+
request("GET", BASE + "/" + escape(id) + "/attachments")
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Retrieve a single inbound attachment with a signed download URL.
|
|
19
|
+
def get(id, attachment_id)
|
|
20
|
+
request("GET", BASE + "/" + escape(id) + "/attachments/" + escape(attachment_id))
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Inbound (received) emails. Reach it at <tt>mailtea.emails.inbound</tt>.
|
|
25
|
+
#
|
|
26
|
+
# List and retrieve mail delivered to your receiving domains, download
|
|
27
|
+
# attachments, and #reply — which threads correctly by construction and reuses
|
|
28
|
+
# the transactional send pipeline. Scoped to a publication: pass
|
|
29
|
+
# +publication_id+ to #list.
|
|
30
|
+
class InboundEmails < Resource
|
|
31
|
+
BASE = "/v1/emails/inbound"
|
|
32
|
+
|
|
33
|
+
# Attachments on a received email.
|
|
34
|
+
attr_reader :attachments
|
|
35
|
+
|
|
36
|
+
def initialize(request)
|
|
37
|
+
super
|
|
38
|
+
@attachments = InboundAttachments.new(request)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# List received emails in a publication (most recent first), cursor-paginated.
|
|
42
|
+
# Takes +publication_id+, optional +limit+ (1-100, default 20) and +cursor+.
|
|
43
|
+
def list(params = nil, **filters)
|
|
44
|
+
request("GET", BASE + query(payload(params, filters)))
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Retrieve a single received email, including its body, headers, and attachments.
|
|
48
|
+
def get(id)
|
|
49
|
+
request("GET", BASE + "/" + escape(id))
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Reply to a received email. The reply target (+to+), the threading headers
|
|
53
|
+
# and the "Re: " subject default are all server-derived — pass only the
|
|
54
|
+
# content. Returns the resulting transactional email's +id+ and +status+.
|
|
55
|
+
def reply(id, params = nil, html: UNSET, text: UNSET, from: UNSET, subject: UNSET,
|
|
56
|
+
cc: UNSET, bcc: UNSET, idempotency_key: UNSET, **rest)
|
|
57
|
+
body = payload(
|
|
58
|
+
params,
|
|
59
|
+
{ html: html, text: text, from: from, subject: subject,
|
|
60
|
+
cc: cc, bcc: bcc, idempotency_key: idempotency_key },
|
|
61
|
+
rest
|
|
62
|
+
)
|
|
63
|
+
request("POST", BASE + "/" + escape(id) + "/reply", body)
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "resource"
|
|
4
|
+
|
|
5
|
+
module Mailtea
|
|
6
|
+
# The +posts+ resource (newsletter posts/issues). Reach it at
|
|
7
|
+
# <tt>mailtea.posts</tt>.
|
|
8
|
+
class Posts < Resource
|
|
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.
|
|
14
|
+
#
|
|
15
|
+
# Returns <tt>{ "id" => ... }</tt>.
|
|
16
|
+
def create(params = nil, publication_id: UNSET, subject: UNSET, html: UNSET,
|
|
17
|
+
text: UNSET, template_id: UNSET, variables: UNSET, from: UNSET,
|
|
18
|
+
reply_to: UNSET, name: UNSET, kind: UNSET, send: UNSET,
|
|
19
|
+
scheduled_at: UNSET, **rest)
|
|
20
|
+
body = payload(
|
|
21
|
+
params,
|
|
22
|
+
{ publication_id: publication_id, subject: subject, html: html, text: text,
|
|
23
|
+
template_id: template_id, variables: variables, from: from,
|
|
24
|
+
reply_to: reply_to, name: name, kind: kind, send: send,
|
|
25
|
+
scheduled_at: scheduled_at },
|
|
26
|
+
rest
|
|
27
|
+
)
|
|
28
|
+
request("POST", "/v1/posts", body)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# List posts (most recent first, offset-paginated). Takes +publication_id+
|
|
32
|
+
# (required) plus optional +limit+, +offset+, +status+ and +kind+
|
|
33
|
+
# ("newsletter" or "broadcast"). Returns <tt>{ "data", "total" }</tt>.
|
|
34
|
+
def list(params = nil, **filters)
|
|
35
|
+
request("GET", "/v1/posts" + query(payload(params, filters)))
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Retrieve a post by id.
|
|
39
|
+
def get(id, params = nil, **filters)
|
|
40
|
+
request("GET", "/v1/posts/" + escape(id) + query(payload(params, filters)))
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Update a draft post (sent posts are immutable). Accepts +subject+, +html+,
|
|
44
|
+
# +text+, +from+, +reply_to+ and +name+.
|
|
45
|
+
def update(id, params = nil, **fields)
|
|
46
|
+
request("PATCH", "/v1/posts/" + escape(id), payload(params, fields))
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Delete a draft post (sent posts cannot be deleted).
|
|
50
|
+
def delete(id, params = nil, **filters)
|
|
51
|
+
request("DELETE", "/v1/posts/" + escape(id) + query(payload(params, filters)))
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Send a draft post to the publication's audience — immediately, or at
|
|
55
|
+
# +scheduled_at+ (ISO 8601) if given. Requires the +issues:send+ scope.
|
|
56
|
+
#
|
|
57
|
+
# Like Emails#send this shadows Object#send on the resource object;
|
|
58
|
+
# +__send__+ is untouched.
|
|
59
|
+
def send(id, params = nil, scheduled_at: UNSET, **rest)
|
|
60
|
+
body = payload(params, { scheduled_at: scheduled_at }, rest)
|
|
61
|
+
# An empty body would fail the schema's parse of "{}" on some proxies and
|
|
62
|
+
# says nothing anyway, so an unscheduled send goes out with no body at all.
|
|
63
|
+
request("POST", "/v1/posts/" + escape(id) + "/send", body.empty? ? nil : body)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Send a TEST copy of a post to specific recipients to check it before
|
|
67
|
+
# subscribers see it. Renders the post exactly as a subscriber would receive
|
|
68
|
+
# it and delivers a one-shot "[TEST]" email — it does NOT send to the audience.
|
|
69
|
+
#
|
|
70
|
+
# Takes +recipients+ (up to 10), +from+ (must use a verified domain) and
|
|
71
|
+
# optional +reply_to+. Returns <tt>{ "sent_to" => [...], "failed_to" => [...] }</tt>.
|
|
72
|
+
def send_test(id, params = nil, recipients: UNSET, from: UNSET, reply_to: UNSET, **rest)
|
|
73
|
+
body = payload(params, { recipients: recipients, from: from, reply_to: reply_to }, rest)
|
|
74
|
+
request("POST", "/v1/posts/" + escape(id) + "/test", body)
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "error"
|
|
4
|
+
require_relative "util"
|
|
5
|
+
|
|
6
|
+
module Mailtea
|
|
7
|
+
# Base class for the resource objects hanging off Mailtea::Client. It holds
|
|
8
|
+
# the client's request callable; everything else lives in the subclasses.
|
|
9
|
+
class Resource
|
|
10
|
+
def initialize(request)
|
|
11
|
+
@request = request
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
private
|
|
15
|
+
|
|
16
|
+
# +raw+ hands back the response body as text instead of parsed JSON, for the
|
|
17
|
+
# handful of endpoints that answer with something other than JSON.
|
|
18
|
+
def request(method, path, body = nil, raw: false)
|
|
19
|
+
@request.call(method, path, body, raw: raw)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def payload(*sources)
|
|
23
|
+
Util.payload(*sources)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def query(params)
|
|
27
|
+
Util.query(params)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def escape(value)
|
|
31
|
+
Util.escape(value)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mailtea
|
|
4
|
+
# An API response: the parsed JSON, as a Hash that accepts Symbol keys too.
|
|
5
|
+
#
|
|
6
|
+
# email["id"] # => "txemail_..."
|
|
7
|
+
# email[:id] # => "txemail_..."
|
|
8
|
+
#
|
|
9
|
+
# Keys are the wire names exactly as the API sends them (snake_case strings) —
|
|
10
|
+
# nothing is renamed on the way in, so what the API reference documents is what
|
|
11
|
+
# you index. Symbol access exists because a Ruby caller writing +email[:id]+
|
|
12
|
+
# right after writing +send(to:, subject:)+ should not get +nil+.
|
|
13
|
+
#
|
|
14
|
+
# +[]+, +[]=+, +fetch+, +key?+, +dig+ and +delete+ all normalize. Hash's bulk
|
|
15
|
+
# mutators (+merge+, +merge!+, +update+) are C-implemented and do not route
|
|
16
|
+
# through +[]=+, so a Symbol key added that way stays a Symbol — merge into a
|
|
17
|
+
# plain Hash if you need that, rather than into a response.
|
|
18
|
+
class Response < Hash
|
|
19
|
+
def [](key)
|
|
20
|
+
super(normalize(key))
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def fetch(key, *args, &block)
|
|
24
|
+
super(normalize(key), *args, &block)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def key?(key)
|
|
28
|
+
super(normalize(key))
|
|
29
|
+
end
|
|
30
|
+
alias has_key? key?
|
|
31
|
+
alias include? key?
|
|
32
|
+
alias member? key?
|
|
33
|
+
|
|
34
|
+
# Writes normalize too, or the class keeps only half its promise: a caller
|
|
35
|
+
# who sets email[:status] and reads email[:status] back would get nil,
|
|
36
|
+
# because the read side went looking for "status". (The JSON parser builds
|
|
37
|
+
# these through []= with String keys, where normalize is a no-op.)
|
|
38
|
+
def []=(key, value)
|
|
39
|
+
super(normalize(key), value)
|
|
40
|
+
end
|
|
41
|
+
alias store []=
|
|
42
|
+
|
|
43
|
+
def delete(key, &block)
|
|
44
|
+
super(normalize(key), &block)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Hash#dig is C-implemented and would not route through the [] above, so a
|
|
48
|
+
# nested lookup with symbols needs its own walk.
|
|
49
|
+
def dig(key, *rest)
|
|
50
|
+
value = self[key]
|
|
51
|
+
return value if rest.empty? || value.nil?
|
|
52
|
+
|
|
53
|
+
value.dig(*rest)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
def normalize(key)
|
|
59
|
+
key.is_a?(Symbol) ? key.to_s : key
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "resource"
|
|
4
|
+
|
|
5
|
+
module Mailtea
|
|
6
|
+
# The +segments+ resource. Reach it at <tt>mailtea.segments</tt>.
|
|
7
|
+
#
|
|
8
|
+
# Audience segments are scoped to a publication — pass +publication_id+. To
|
|
9
|
+
# clear a nullable filter on update pass +nil+ (e.g. <tt>status_filter: nil</tt>);
|
|
10
|
+
# omit the keyword to leave it unchanged.
|
|
11
|
+
class Segments < Resource
|
|
12
|
+
# Create a segment. Takes +publication_id+ and +name+, plus optional
|
|
13
|
+
# +description+, +status_filter+ and +query_filter+.
|
|
14
|
+
def create(params = nil, **fields)
|
|
15
|
+
request("POST", "/v1/segments", payload(params, fields))
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# List segments. Filters: +publication_id+ (required), +limit+, +after+.
|
|
19
|
+
def list(params = nil, **filters)
|
|
20
|
+
request("GET", "/v1/segments" + query(payload(params, filters)))
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Retrieve a segment. Requires +publication_id+.
|
|
24
|
+
def get(id, params = nil, **filters)
|
|
25
|
+
request("GET", "/v1/segments/" + escape(id) + query(payload(params, filters)))
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Update a segment's +name+, +description+, +status_filter+ or
|
|
29
|
+
# +query_filter+. +publication_id+ is required and goes in the query string.
|
|
30
|
+
def update(id, params = nil, **fields)
|
|
31
|
+
scope, body = Util.split_publication(payload(params, fields))
|
|
32
|
+
request("PATCH", "/v1/segments/" + escape(id) + scope, body)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Delete a segment. Requires +publication_id+.
|
|
36
|
+
def delete(id, params = nil, **filters)
|
|
37
|
+
request("DELETE", "/v1/segments/" + escape(id) + query(payload(params, filters)))
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "resource"
|
|
4
|
+
|
|
5
|
+
module Mailtea
|
|
6
|
+
# The +senders+ resource (named From identities). Reach it at
|
|
7
|
+
# <tt>mailtea.senders</tt>.
|
|
8
|
+
#
|
|
9
|
+
# Senders are scoped to a publication — pass +publication_id+. #create takes
|
|
10
|
+
# +name+ and +email+ (the address must live on a verified, DKIM-verified email
|
|
11
|
+
# domain), plus optional +reply_to+ and +is_default+. The +email+ is
|
|
12
|
+
# immutable, so #update only changes +name+, +reply_to+ and +is_default+.
|
|
13
|
+
class Senders < Resource
|
|
14
|
+
def create(params = nil, **fields)
|
|
15
|
+
request("POST", "/v1/senders", payload(params, fields))
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# List senders (cursor-paginated). Filters: +publication_id+ (required),
|
|
19
|
+
# +limit+, +after+ (cursor from a previous +next_cursor+).
|
|
20
|
+
def list(params = nil, **filters)
|
|
21
|
+
request("GET", "/v1/senders" + query(payload(params, filters)))
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Retrieve a sender. Requires +publication_id+.
|
|
25
|
+
def get(id, params = nil, **filters)
|
|
26
|
+
request("GET", "/v1/senders/" + escape(id) + query(payload(params, filters)))
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Update a sender's +name+, +reply_to+ or +is_default+ (the +email+ is
|
|
30
|
+
# immutable). +publication_id+ is required, in the body.
|
|
31
|
+
def update(id, params = nil, **fields)
|
|
32
|
+
request("PATCH", "/v1/senders/" + escape(id), payload(params, fields))
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Delete a sender. Requires +publication_id+.
|
|
36
|
+
def delete(id, params = nil, **filters)
|
|
37
|
+
request("DELETE", "/v1/senders/" + escape(id) + query(payload(params, filters)))
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "resource"
|
|
4
|
+
|
|
5
|
+
module Mailtea
|
|
6
|
+
# The +suppressions+ resource (the org-wide do-not-send list). Reach it at
|
|
7
|
+
# <tt>mailtea.suppressions</tt>.
|
|
8
|
+
#
|
|
9
|
+
# Suppressions are team-scoped — there is no +publication_id+.
|
|
10
|
+
class Suppressions < Resource
|
|
11
|
+
# List suppression entries (cursor-paginated). Optional filters: +reason+,
|
|
12
|
+
# +q+ (email search), +created_after+, +created_before+, +limit+,
|
|
13
|
+
# +starting_after+ (cursor from a previous +next_cursor+).
|
|
14
|
+
def list(params = nil, **filters)
|
|
15
|
+
request("GET", "/v1/suppressions" + query(payload(params, filters)))
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Add addresses to the suppression list. Takes +emails+ (an Array, up to
|
|
19
|
+
# 1000) and an optional +reason+. Returns <tt>{ "added" => ... }</tt>.
|
|
20
|
+
def add(params = nil, **fields)
|
|
21
|
+
request("POST", "/v1/suppressions", payload(params, fields))
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Remove addresses from the suppression list. Takes +emails+ (an Array).
|
|
25
|
+
# Returns <tt>{ "removed" => ... }</tt>.
|
|
26
|
+
def remove(params = nil, **fields)
|
|
27
|
+
request("DELETE", "/v1/suppressions", payload(params, fields))
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Export the whole suppression list as CSV. Returns the raw text/csv body
|
|
31
|
+
# ("email,reason,source,created_at" with a header row) as a String, not a
|
|
32
|
+
# parsed response.
|
|
33
|
+
def export
|
|
34
|
+
request("GET", "/v1/suppressions/export", nil, raw: true)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "resource"
|
|
4
|
+
|
|
5
|
+
module Mailtea
|
|
6
|
+
# The +templates+ resource (reusable server-side email templates). Reach it at
|
|
7
|
+
# <tt>mailtea.templates</tt>.
|
|
8
|
+
#
|
|
9
|
+
# Templates are scoped to a publication — pass +publication_id+ (except
|
|
10
|
+
# #render, which just renders a spec). Create one from raw +html+, a
|
|
11
|
+
# json-render +spec+, or an +editor_doc+ (a Studio editor design), then
|
|
12
|
+
# #publish it before seeding posts/emails from it.
|
|
13
|
+
class Templates < Resource
|
|
14
|
+
# Render a json-render +spec+ (with optional +variables+) to HTML without
|
|
15
|
+
# creating a template. Returns <tt>{ "html" => ..., "text" => ... }</tt>.
|
|
16
|
+
def render(params = nil, **fields)
|
|
17
|
+
request("POST", "/v1/templates/render", payload(params, fields))
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Create a template from +html+, a +spec+, OR an +editor_doc+ (exactly one
|
|
21
|
+
# is required — the server renders +html+ from an +editor_doc+, so do not
|
|
22
|
+
# send both). Takes +publication_id+ and +name+, plus optional
|
|
23
|
+
# +style_profile+, +mailtea_theme+, +global_css+, +category+,
|
|
24
|
+
# +preview_image_url+, +tags+, +description+, +text+, +subject+, +from+,
|
|
25
|
+
# +reply_to+ and +variables+.
|
|
26
|
+
def create(params = nil, **fields)
|
|
27
|
+
request("POST", "/v1/templates", payload(params, fields))
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# List templates (cursor-paginated). Filters: +publication_id+ (required),
|
|
31
|
+
# +limit+, +after+ (cursor from a previous +next_cursor+).
|
|
32
|
+
def list(params = nil, **filters)
|
|
33
|
+
request("GET", "/v1/templates" + query(payload(params, filters)))
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Retrieve a template. Requires +publication_id+.
|
|
37
|
+
def get(id, params = nil, **filters)
|
|
38
|
+
request("GET", "/v1/templates/" + escape(id) + query(payload(params, filters)))
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Update a template's +name+, +html+/+spec+/+editor_doc+, +style_profile+,
|
|
42
|
+
# +mailtea_theme+, +global_css+, +category+, +preview_image_url+, +tags+,
|
|
43
|
+
# +description+, +text+, +subject+, +from+, +reply_to+ or +variables+. An
|
|
44
|
+
# +editor_doc+ re-renders +html+ server-side, so do not send both.
|
|
45
|
+
#
|
|
46
|
+
# +global_css+, +category+, +preview_image_url+, +tags+, +text+, +subject+,
|
|
47
|
+
# +from+ and +reply_to+ accept +nil+ to clear them. +publication_id+ is
|
|
48
|
+
# required and goes in the query string.
|
|
49
|
+
def update(id, params = nil, **fields)
|
|
50
|
+
scope, body = Util.split_publication(payload(params, fields))
|
|
51
|
+
request("PATCH", "/v1/templates/" + escape(id) + scope, body)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Publish a template so it can seed posts/emails. Requires +publication_id+.
|
|
55
|
+
def publish(id, params = nil, **filters)
|
|
56
|
+
request("POST", "/v1/templates/" + escape(id) + "/publish" + query(payload(params, filters)))
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# 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+.
|
|
62
|
+
def unpublish(id, params = nil, **filters)
|
|
63
|
+
request("POST", "/v1/templates/" + escape(id) + "/unpublish" + query(payload(params, filters)))
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# List a template's design history, newest first. Requires +publication_id+;
|
|
67
|
+
# optional +limit+ (the server caps it at the retained maximum).
|
|
68
|
+
#
|
|
69
|
+
# Entries are metadata only — +version+, +origin+ ("edit", "publish" or
|
|
70
|
+
# "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.
|
|
76
|
+
#
|
|
77
|
+
# The reply also carries +retention+: only the newest +max_versions+ are
|
|
78
|
+
# kept, and consecutive edits by the same author within
|
|
79
|
+
# +coalesce_window_seconds+ collapse into one entry.
|
|
80
|
+
def versions(id, params = nil, **filters)
|
|
81
|
+
request("GET", "/v1/templates/" + escape(id) + "/versions" + query(payload(params, filters)))
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Put an older design from #versions back onto the template. Requires
|
|
85
|
+
# +publication_id+.
|
|
86
|
+
#
|
|
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.
|
|
91
|
+
#
|
|
92
|
+
# History is forward-only: the design being replaced is recorded as its own
|
|
93
|
+
# version first, then the restored design is appended as the new newest one.
|
|
94
|
+
# Nothing is rewound or deleted, so a restore is itself undone by restoring
|
|
95
|
+
# the entry directly above it.
|
|
96
|
+
#
|
|
97
|
+
# Restoring the design that is already current writes nothing and returns
|
|
98
|
+
# <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+.
|
|
103
|
+
def restore_version(id, version, params = nil, **filters)
|
|
104
|
+
request(
|
|
105
|
+
"POST",
|
|
106
|
+
"/v1/templates/" + escape(id) + "/versions/" + escape(version) + "/restore" +
|
|
107
|
+
query(payload(params, filters))
|
|
108
|
+
)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Duplicate a template into a new draft. Requires +publication_id+.
|
|
112
|
+
def duplicate(id, params = nil, **filters)
|
|
113
|
+
request("POST", "/v1/templates/" + escape(id) + "/duplicate" + query(payload(params, filters)))
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Delete a template. Requires +publication_id+.
|
|
117
|
+
def delete(id, params = nil, **filters)
|
|
118
|
+
request("DELETE", "/v1/templates/" + escape(id) + query(payload(params, filters)))
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "resource"
|
|
4
|
+
|
|
5
|
+
module Mailtea
|
|
6
|
+
# The +topics+ resource (topic definitions). Reach it at <tt>mailtea.topics</tt>.
|
|
7
|
+
#
|
|
8
|
+
# Topics are scoped to a publication — pass +publication_id+. This manages
|
|
9
|
+
# topic definitions only; assigning topics to contacts is not yet exposed.
|
|
10
|
+
class Topics < Resource
|
|
11
|
+
# Create a topic definition. Requires +publication_id+, +name+ and
|
|
12
|
+
# +default_subscription+ ("opt_in" or "opt_out"). Optional +description+ and
|
|
13
|
+
# +visibility+ ("private" by default; "public" makes the topic appear on the
|
|
14
|
+
# reader preference page as its own subscription).
|
|
15
|
+
def create(params = nil, publication_id: UNSET, name: UNSET,
|
|
16
|
+
default_subscription: UNSET, description: UNSET, visibility: UNSET, **rest)
|
|
17
|
+
body = payload(
|
|
18
|
+
params,
|
|
19
|
+
{ publication_id: publication_id, name: name,
|
|
20
|
+
default_subscription: default_subscription, description: description,
|
|
21
|
+
visibility: visibility },
|
|
22
|
+
rest
|
|
23
|
+
)
|
|
24
|
+
request("POST", "/v1/topics", body)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# List topic definitions. Filters: +publication_id+ (required), +limit+, +after+.
|
|
28
|
+
def list(params = nil, **filters)
|
|
29
|
+
request("GET", "/v1/topics" + query(payload(params, filters)))
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Retrieve a topic definition. Requires +publication_id+.
|
|
33
|
+
def get(id, params = nil, **filters)
|
|
34
|
+
request("GET", "/v1/topics/" + escape(id) + query(payload(params, filters)))
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Update a topic's +name+, +description+, +default_subscription+ or
|
|
38
|
+
# +visibility+. +publication_id+ is required and goes in the query string.
|
|
39
|
+
def update(id, params = nil, **fields)
|
|
40
|
+
scope, body = Util.split_publication(payload(params, fields))
|
|
41
|
+
request("PATCH", "/v1/topics/" + escape(id) + scope, body)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Delete a topic definition. Requires +publication_id+.
|
|
45
|
+
def delete(id, params = nil, **filters)
|
|
46
|
+
request("DELETE", "/v1/topics/" + escape(id) + query(payload(params, filters)))
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "net/http"
|
|
5
|
+
require "openssl"
|
|
6
|
+
require "uri"
|
|
7
|
+
|
|
8
|
+
require_relative "error"
|
|
9
|
+
|
|
10
|
+
module Mailtea
|
|
11
|
+
# What a transport hands back: the HTTP status, the response headers with
|
|
12
|
+
# lowercased names, and the body as an undecoded String.
|
|
13
|
+
HttpResponse = Struct.new(:status, :headers, :body, keyword_init: true)
|
|
14
|
+
|
|
15
|
+
# The default HTTP transport: +net/http+ and +json+ from the standard library,
|
|
16
|
+
# no gems.
|
|
17
|
+
#
|
|
18
|
+
# A transport is anything that responds to
|
|
19
|
+
# <tt>call(method, url, headers, body) -> HttpResponse</tt>. Pass your own to
|
|
20
|
+
# <tt>Mailtea::Client.new(..., transport:)</tt> to record requests in tests, to
|
|
21
|
+
# route through a proxy or an instrumented HTTP stack, or to reuse a connection
|
|
22
|
+
# pool. Everything the client does above the wire — auth header, JSON encoding,
|
|
23
|
+
# error mapping — stays the same.
|
|
24
|
+
module Transport
|
|
25
|
+
VERBS = {
|
|
26
|
+
"GET" => Net::HTTP::Get,
|
|
27
|
+
"POST" => Net::HTTP::Post,
|
|
28
|
+
"PATCH" => Net::HTTP::Patch,
|
|
29
|
+
"DELETE" => Net::HTTP::Delete
|
|
30
|
+
}.freeze
|
|
31
|
+
|
|
32
|
+
OPEN_TIMEOUT_SECONDS = 10
|
|
33
|
+
READ_TIMEOUT_SECONDS = 30
|
|
34
|
+
|
|
35
|
+
module_function
|
|
36
|
+
|
|
37
|
+
def call(method, url, headers, body)
|
|
38
|
+
uri = parse_url(url)
|
|
39
|
+
request_class = VERBS.fetch(method) do
|
|
40
|
+
raise Error.new("Unsupported HTTP method #{method}", code: "unsupported_method")
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
request = request_class.new(uri)
|
|
44
|
+
headers.each { |name, value| request[name] = value }
|
|
45
|
+
# Net::HTTP::Delete has no request body by convention, but the API's
|
|
46
|
+
# suppressions removal takes one, and setting it here still sends it.
|
|
47
|
+
request.body = body if body
|
|
48
|
+
# A bodyless POST (cancel, publish, verify, activate…) is given an empty
|
|
49
|
+
# body by Net::HTTP, which then labels it application/x-www-form-urlencoded
|
|
50
|
+
# — a lie about a JSON API, and a warning under -w. Say what this client
|
|
51
|
+
# actually speaks instead.
|
|
52
|
+
if body.nil? && request.request_body_permitted?
|
|
53
|
+
request["Content-Type"] ||= "application/json"
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
response = Net::HTTP.start(
|
|
57
|
+
uri.hostname,
|
|
58
|
+
uri.port,
|
|
59
|
+
use_ssl: uri.scheme == "https",
|
|
60
|
+
open_timeout: OPEN_TIMEOUT_SECONDS,
|
|
61
|
+
read_timeout: READ_TIMEOUT_SECONDS
|
|
62
|
+
) { |http| http.request(request) }
|
|
63
|
+
|
|
64
|
+
HttpResponse.new(
|
|
65
|
+
status: response.code.to_i,
|
|
66
|
+
headers: lowercased(response),
|
|
67
|
+
body: response.body.to_s
|
|
68
|
+
)
|
|
69
|
+
rescue Timeout::Error, SystemCallError, SocketError, IOError, OpenSSL::SSL::SSLError,
|
|
70
|
+
URI::InvalidURIError => e
|
|
71
|
+
# A request that never lands is as much a failed send as a 500, and the
|
|
72
|
+
# caller should only have to rescue one class. status 0 says it never
|
|
73
|
+
# reached the API.
|
|
74
|
+
# parse_url guarantees a URI::HTTP by here, so host and port are known.
|
|
75
|
+
raise Error.new(
|
|
76
|
+
"Could not reach Mailtea at #{uri.scheme}://#{uri.host}:#{uri.port}: " \
|
|
77
|
+
"#{e.class}: #{e.message}",
|
|
78
|
+
code: "connection_error"
|
|
79
|
+
)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# A base URL that is not http(s) is a configuration mistake, not a network
|
|
83
|
+
# fault, and it has to arrive as Mailtea::Error like every other failure —
|
|
84
|
+
# the whole point of the one-rescue contract is that nothing else escapes.
|
|
85
|
+
# Left alone, the two ways to get it wrong both break that: Net::HTTP.start
|
|
86
|
+
# answers "api.mailtea.app" (no scheme) with a bare ArgumentError, and
|
|
87
|
+
# URI.parse answers "127.0.0.1:7787" — the classic MAILTEA_API_BASE_URL
|
|
88
|
+
# typo, since a host:port reads as scheme:opaque — with InvalidURIError.
|
|
89
|
+
def parse_url(url)
|
|
90
|
+
uri = URI.parse(url)
|
|
91
|
+
return uri if uri.is_a?(URI::HTTP) # URI::HTTPS is a subclass
|
|
92
|
+
|
|
93
|
+
raise invalid_base_url(url)
|
|
94
|
+
rescue URI::InvalidURIError
|
|
95
|
+
raise invalid_base_url(url)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def invalid_base_url(url)
|
|
99
|
+
# Report the base rather than the whole URL: every path this client builds
|
|
100
|
+
# starts at "/v1", so the split leaves exactly the configured part — and
|
|
101
|
+
# keeps recipient addresses out of the message when the query held one.
|
|
102
|
+
Error.new(
|
|
103
|
+
"Mailtea's base URL must be an http:// or https:// URL — got " \
|
|
104
|
+
"#{url.split("/v1", 2).first.inspect}. Check MAILTEA_API_BASE_URL, or the " \
|
|
105
|
+
"base_url: passed to Mailtea::Client.new.",
|
|
106
|
+
code: "invalid_base_url"
|
|
107
|
+
)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def lowercased(response)
|
|
111
|
+
headers = {}
|
|
112
|
+
response.each_header { |name, value| headers[name.downcase] = value }
|
|
113
|
+
headers
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|