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