mailcycle 0.1.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,202 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mailcycle
4
+ # API keys, webhooks and sessions.
5
+ #
6
+ # The names on keys and webhooks are sealed here under the vault key, bound
7
+ # to the record's kind and id, exactly as the app seals them, so the server
8
+ # stores none. The id is chosen here for that reason: the name has to be
9
+ # bound to it before the record exists.
10
+ #
11
+ # Minting and revoking keys, and adding, changing and removing webhooks, need
12
+ # a signed-in session. The API answers `session_required` to an API key.
13
+ module Names
14
+ API_KEY_RECORD = "api-key"
15
+ WEBHOOK_RECORD = "webhook"
16
+
17
+ module_function
18
+
19
+ # A record's name, sealed as `{"name": ...}` under the vault key.
20
+ def seal_name(client, kind, id, name)
21
+ Vault.seal(client.require_keys("seal a name"), kind, id, { "name" => name })
22
+ end
23
+
24
+ # The name, or nil if there is no phrase or it will not open.
25
+ #
26
+ # A name that will not open is shown as unreadable rather than failing the
27
+ # list, the same as an address label.
28
+ def open_name(client, kind, id, meta)
29
+ Wire.maybe_text(Vault.open_or_empty(client.keys, kind, id, meta)["name"])
30
+ end
31
+ end
32
+
33
+ # API keys: list, create and revoke. Session only.
34
+ class ApiKeys
35
+ def initialize(client)
36
+ @client = client
37
+ end
38
+
39
+ # The account's keys, newest first, with their names opened.
40
+ def list
41
+ Wire.array(Wire.object(@client.http.request("GET", "/api-keys"))["apiKeys"]).map { |item| to_api_key(item) }
42
+ end
43
+
44
+ # Makes a key. Operator plan and up.
45
+ #
46
+ # The token comes back once, here. Keep it somewhere private; the API
47
+ # holds only its hash and cannot show it again.
48
+ def create(name)
49
+ id = Vault.new_id("key", 16)
50
+ meta = Names.seal_name(@client, Names::API_KEY_RECORD, id, name)
51
+ result = Wire.object(@client.http.request("POST", "/api-keys", body: { "id" => id, "meta" => meta }))
52
+ CreatedApiKey.new(api_key: to_api_key(result["apiKey"]), token: Wire.text(result["token"]))
53
+ end
54
+
55
+ # Revokes a key. It stops working at once.
56
+ def revoke(key_id)
57
+ @client.http.request("DELETE", "/api-keys/#{Mailcycle.path_segment(key_id)}")
58
+ nil
59
+ end
60
+
61
+ private
62
+
63
+ def to_api_key(wire)
64
+ wire = Wire.object(wire)
65
+ id = Wire.text(wire["id"])
66
+ ApiKey.new(
67
+ id: id,
68
+ name: Names.open_name(@client, Names::API_KEY_RECORD, id, wire["meta"]),
69
+ hint: Wire.text(wire["hint"]),
70
+ created_at: Wire.text(wire["createdAt"]),
71
+ last_used_at: Wire.maybe_text(wire["lastUsedAt"])
72
+ )
73
+ end
74
+ end
75
+
76
+ # Webhooks, Operator and up.
77
+ #
78
+ # A session can do everything. An API key can list, read deliveries, send a
79
+ # test and redeliver.
80
+ class Webhooks
81
+ def initialize(client)
82
+ @client = client
83
+ end
84
+
85
+ # The account's webhooks, with their names opened, and every event type
86
+ # one can subscribe to.
87
+ def list
88
+ result = Wire.object(@client.http.request("GET", "/webhook-endpoints"))
89
+ WebhookList.new(webhooks: Wire.array(result["webhooks"]).map { |item| to_webhook(item) },
90
+ event_types: Wire.strings(result["eventTypes"]))
91
+ end
92
+
93
+ # Adds a webhook. `events` are event types, or `["*"]` for all of them.
94
+ #
95
+ # The signing secret comes back once, here. Keep it to check deliveries
96
+ # with `Mailcycle.verify_webhook_signature`.
97
+ def create(url, events, name)
98
+ id = Vault.new_id("whk", 16)
99
+ meta = Names.seal_name(@client, Names::WEBHOOK_RECORD, id, name)
100
+ body = { "id" => id, "url" => url, "events" => events, "meta" => meta }
101
+ result = Wire.object(@client.http.request("POST", "/webhook-endpoints", body: body))
102
+ CreatedWebhook.new(webhook: to_webhook(result["webhook"]), secret: Wire.text(result["secret"]))
103
+ end
104
+
105
+ # Changes what is given and leaves the rest. `enabled` switches it on or
106
+ # off.
107
+ def update(webhook_id, url: nil, events: nil, name: nil, enabled: nil)
108
+ body = {}
109
+ body["url"] = url unless url.nil?
110
+ body["events"] = events unless events.nil?
111
+ body["meta"] = Names.seal_name(@client, Names::WEBHOOK_RECORD, webhook_id, name) unless name.nil?
112
+ body["status"] = enabled ? "active" : "disabled" unless enabled.nil?
113
+ to_webhook(Wire.object(@client.http.request("PATCH", path(webhook_id), body: body))["webhook"])
114
+ end
115
+
116
+ # Removes the webhook and its delivery log.
117
+ def delete(webhook_id)
118
+ @client.http.request("DELETE", path(webhook_id))
119
+ nil
120
+ end
121
+
122
+ # A new signing secret, returned once. The old one stops at once.
123
+ def rotate_secret(webhook_id)
124
+ Wire.text(Wire.object(@client.http.request("POST", "#{path(webhook_id)}/rotate-secret", body: {}))["secret"])
125
+ end
126
+
127
+ # Sends a `webhook.test` event to it.
128
+ def test(webhook_id)
129
+ queued(@client.http.request("POST", "#{path(webhook_id)}/test", body: {}))
130
+ end
131
+
132
+ # The delivery log, newest first.
133
+ #
134
+ # `cursor` is the `next_cursor` of the page before, passed back as it is.
135
+ # `limit` is up to 200, 50 when it is not given.
136
+ def deliveries(webhook_id, cursor: nil, limit: nil)
137
+ query = []
138
+ query << ["cursor", cursor] if cursor
139
+ query << ["limit", limit.to_s] if limit
140
+ result = Wire.object(@client.http.request("GET", "#{path(webhook_id)}/deliveries", query: query))
141
+ DeliveryPage.new(deliveries: Wire.array(result["deliveries"]).map { |item| WebhookDelivery.from_wire(item) },
142
+ next_cursor: Wire.maybe_text(result["nextCursor"]))
143
+ end
144
+
145
+ # Sends a delivery's event again, with the same event id.
146
+ def redeliver(webhook_id, delivery_id)
147
+ route = "#{path(webhook_id)}/deliveries/#{Mailcycle.path_segment(delivery_id)}/redeliver"
148
+ queued(@client.http.request("POST", route, body: {}))
149
+ end
150
+
151
+ private
152
+
153
+ def path(id)
154
+ "/webhook-endpoints/#{Mailcycle.path_segment(id)}"
155
+ end
156
+
157
+ def queued(result)
158
+ result = Wire.object(result)
159
+ QueuedDelivery.new(delivery_id: Wire.text(result["deliveryId"]), event_id: Wire.maybe_text(result["eventId"]))
160
+ end
161
+
162
+ def to_webhook(wire)
163
+ wire = Wire.object(wire)
164
+ id = Wire.text(wire["id"])
165
+ Webhook.new(
166
+ id: id,
167
+ name: Names.open_name(@client, Names::WEBHOOK_RECORD, id, wire["meta"]),
168
+ url: Wire.text(wire["url"]),
169
+ events: Wire.strings(wire["events"]),
170
+ status: Wire.text(wire["status"]),
171
+ disabled_reason: Wire.maybe_text(wire["disabledReason"]),
172
+ failing_since: Wire.maybe_text(wire["failingSince"]),
173
+ last_success_at: Wire.maybe_text(wire["lastSuccessAt"]),
174
+ created_at: Wire.text(wire["createdAt"])
175
+ )
176
+ end
177
+ end
178
+
179
+ # Signed-in sessions. Session only.
180
+ class Sessions
181
+ def initialize(client)
182
+ @client = client
183
+ end
184
+
185
+ # The account's live sessions, newest first. `current` marks this one.
186
+ def list
187
+ Wire.array(Wire.object(@client.http.request("GET", "/accounts/sessions"))["sessions"])
188
+ .map { |item| Session.from_wire(item) }
189
+ end
190
+
191
+ # Ends one session.
192
+ def revoke(session_id)
193
+ @client.http.request("DELETE", "/accounts/sessions/#{Mailcycle.path_segment(session_id)}")
194
+ nil
195
+ end
196
+
197
+ # Ends every session but this one. Returns how many ended.
198
+ def revoke_others
199
+ Wire.count(Wire.object(@client.http.request("DELETE", "/accounts/sessions"))["revoked"])
200
+ end
201
+ end
202
+ end
@@ -0,0 +1,243 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mailcycle
4
+ # The shapes a caller sees.
5
+ #
6
+ # Each one carries the fields the API documents. Where the API's record has
7
+ # room for more (a plan, a domain), anything it adds later is kept in `extra`
8
+ # rather than dropped, so a new server field does not need a new release to
9
+ # reach a caller.
10
+ module Wire
11
+ module_function
12
+
13
+ # Reading a server's JSON, which may be any shape at all: the value as the
14
+ # type asked for, or the fallback.
15
+ def object(value)
16
+ value.is_a?(Hash) ? value : {}
17
+ end
18
+
19
+ def array(value)
20
+ value.is_a?(Array) ? value : []
21
+ end
22
+
23
+ def text(value, fallback = "")
24
+ value.is_a?(String) ? value : fallback
25
+ end
26
+
27
+ def maybe_text(value)
28
+ value.is_a?(String) ? value : nil
29
+ end
30
+
31
+ def strings(value)
32
+ array(value).grep(String)
33
+ end
34
+
35
+ def count(value)
36
+ value.is_a?(Integer) && value >= 0 ? value : 0
37
+ end
38
+
39
+ def flag(value, fallback)
40
+ [true, false].include?(value) ? value : fallback
41
+ end
42
+ end
43
+
44
+ Plan = Struct.new(:id, :name, :extra, keyword_init: true) do
45
+ def self.from_wire(wire)
46
+ wire = Wire.object(wire)
47
+ new(id: Wire.text(wire["id"]), name: Wire.text(wire["name"]), extra: wire.except("id", "name"))
48
+ end
49
+ end
50
+
51
+ # `plan_id` is the account's own plan id, and `plan` is that plan's record.
52
+ Account = Struct.new(:id, :plan_id, :created_at, :plan, keyword_init: true)
53
+
54
+ # An email address on the account.
55
+ #
56
+ # `status` is `active` when assigned to a device, `unassigned` otherwise.
57
+ # `label` is the label set in the app, nil without the phrase or if it will
58
+ # not open. `sender_name` is the name mail from the address goes out under,
59
+ # nil without the phrase or if none is set. `receives` says whether the
60
+ # address can receive mail: it was created with its public key.
61
+ Address = Struct.new(:id, :email_address, :status, :created_at, :retention_days, :assigned_worker_id,
62
+ :paused_at, :label, :sender_name, :receives, keyword_init: true)
63
+
64
+ # `reserved` is true for a domain that belongs to this account alone.
65
+ Domain = Struct.new(:domain, :reserved, :extra, keyword_init: true) do
66
+ def self.from_wire(wire)
67
+ wire = Wire.object(wire)
68
+ new(domain: Wire.text(wire["domain"]), reserved: Wire.flag(wire["reserved"], false),
69
+ extra: wire.except("domain", "reserved"))
70
+ end
71
+ end
72
+
73
+ # One file on a received message.
74
+ #
75
+ # `content_id` is set on a part the HTML shows inline, where it says `cid:`
76
+ # and this id. `stored` is false when the part had no body; fetching it would
77
+ # answer 404.
78
+ Attachment = Struct.new(:index, :filename, :mime_type, :size, :content_id, :stored, keyword_init: true)
79
+
80
+ # A message, opened on this machine.
81
+ #
82
+ # - `sent_via`: for sent mail, `app`, `api`, `device`, `agent` or `member`
83
+ # (a team member).
84
+ # - `key_epoch`: the address key epoch it was sealed under. 0 unless the
85
+ # address's keys were rotated.
86
+ # - `opened`: false when the content could not be opened: no phrase, or the
87
+ # wrong one.
88
+ # - `reply_to`: where a reply goes: Reply-To if the sender set one,
89
+ # otherwise From.
90
+ # - `html`: the HTML part as sent, remote images and all. Do not render this
91
+ # one.
92
+ # - `safe_html`: the HTML with everything remote removed, as the app shows
93
+ # it. `blocked` is what it had taken out of it, and who it would have told.
94
+ # - `message_id`: the sender's Message-ID, for threading a reply.
95
+ Message = Struct.new(:id, :address_id, :received_at, :read, :starred, :folder, :size_bytes, :sent_via,
96
+ :key_epoch, :opened, :from_address, :from_name, :reply_to, :to, :subject, :text, :html,
97
+ :safe_html, :blocked, :message_id, :attachments, keyword_init: true)
98
+
99
+ # `next_cursor` is opaque. Pass it back as it is to get the next page; nil
100
+ # on the last.
101
+ MessagePage = Struct.new(:messages, :next_cursor, keyword_init: true)
102
+
103
+ # What a send returns. `message_id` is the Message-ID the sending service
104
+ # gave it. `sent_copy_id` is the id of the copy kept in Sent, which the
105
+ # message calls take; nil when no copy was kept, as for an address without a
106
+ # public key.
107
+ SendResult = Struct.new(:message_id, :sent_copy_id, keyword_init: true)
108
+
109
+ # A paired device or agent. `name` and `tags` are what was set in the app;
110
+ # nil and empty without the phrase.
111
+ Device = Struct.new(:id, :platform, :kind, :send_mode, :status, :created_at, :last_active_at, :paused_at,
112
+ :name, :tags, keyword_init: true)
113
+
114
+ # One account event from the log: ids and times, never content.
115
+ ActivityEntry = Struct.new(:id, :kind, :created_at, :address_id, :worker_id, keyword_init: true) do
116
+ def self.from_wire(wire)
117
+ wire = Wire.object(wire)
118
+ new(id: Wire.text(wire["id"]), kind: Wire.text(wire["kind"]), created_at: Wire.text(wire["createdAt"]),
119
+ address_id: Wire.maybe_text(wire["inboxId"]), worker_id: Wire.maybe_text(wire["workerId"]))
120
+ end
121
+ end
122
+
123
+ # One page of the event log, newest first. `next_cursor` is opaque: pass it
124
+ # back as it is to get the page before; nil on the last.
125
+ ActivityPage = Struct.new(:activity, :next_cursor, keyword_init: true)
126
+
127
+ # One event from the live stream. Events carry ids, never content.
128
+ AccountEvent = Struct.new(:id, :event_type, :created_at, :payload, keyword_init: true) do
129
+ # The event a wire value describes, or nil if it is not one.
130
+ def self.from_wire(wire)
131
+ return nil unless wire.is_a?(Hash)
132
+
133
+ id, type, created = wire.values_at("id", "type", "createdAt")
134
+ return nil unless [id, type, created].all? { |field| field.nil? || field.is_a?(String) }
135
+
136
+ payload = wire["payload"]
137
+ return nil unless payload.nil? || payload.is_a?(Hash)
138
+
139
+ new(id: id || "", event_type: type || "", created_at: created || "", payload: payload || {})
140
+ end
141
+ end
142
+
143
+ # An API key. The token itself is shown once, when it is created.
144
+ #
145
+ # `name` is the name set when it was made, opened with the phrase; nil if it
146
+ # will not open. `hint` is the token's last four characters. `last_used_at`
147
+ # is rounded down to five minutes, and nil until the key is used.
148
+ ApiKey = Struct.new(:id, :name, :hint, :created_at, :last_used_at, keyword_init: true)
149
+
150
+ # A new API key and its token. The token is not shown again.
151
+ CreatedApiKey = Struct.new(:api_key, :token, keyword_init: true)
152
+
153
+ # A webhook. `name` is opened with the phrase, nil if it will not open.
154
+ # `events` are event types, or `["*"]` for every event. `status` is `active`
155
+ # or `disabled`. `failing_since` is set while every attempt since the last
156
+ # success has failed.
157
+ Webhook = Struct.new(:id, :name, :url, :events, :status, :disabled_reason, :failing_since, :last_success_at,
158
+ :created_at, keyword_init: true)
159
+
160
+ # The account's webhooks, and every event type one can ask for.
161
+ WebhookList = Struct.new(:webhooks, :event_types, keyword_init: true)
162
+
163
+ # A new webhook and its signing secret. The secret is not shown again.
164
+ CreatedWebhook = Struct.new(:webhook, :secret, keyword_init: true)
165
+
166
+ # One attempt to deliver an event to a webhook.
167
+ #
168
+ # `event` is the body as sent: type, ids and times, never content. `status`
169
+ # is `pending`, `succeeded` or `failed`.
170
+ WebhookDelivery = Struct.new(:id, :event_id, :event_type, :event, :status, :attempts, :response_status, :error,
171
+ :created_at, :last_attempt_at, :next_attempt_at, keyword_init: true) do
172
+ def self.from_wire(wire)
173
+ wire = Wire.object(wire)
174
+ status = wire["responseStatus"]
175
+ new(
176
+ id: Wire.text(wire["id"]),
177
+ event_id: Wire.text(wire["eventId"]),
178
+ event_type: Wire.text(wire["eventType"]),
179
+ event: AccountEvent.from_wire(wire["event"]) || AccountEvent.new(id: "", event_type: "", created_at: "",
180
+ payload: {}),
181
+ status: Wire.text(wire["status"]),
182
+ attempts: Wire.count(wire["attempts"]),
183
+ response_status: status.is_a?(Integer) ? status : nil,
184
+ error: Wire.maybe_text(wire["error"]),
185
+ created_at: Wire.text(wire["createdAt"]),
186
+ last_attempt_at: Wire.maybe_text(wire["lastAttemptAt"]),
187
+ next_attempt_at: Wire.maybe_text(wire["nextAttemptAt"])
188
+ )
189
+ end
190
+ end
191
+
192
+ # `next_cursor` is opaque: pass it back as it is to get the next page; nil on
193
+ # the last.
194
+ DeliveryPage = Struct.new(:deliveries, :next_cursor, keyword_init: true)
195
+
196
+ # The delivery a test or a redelivery queued. `event_id` is set for a test,
197
+ # which makes a new event. A redelivery sends the original event again, id
198
+ # and all.
199
+ QueuedDelivery = Struct.new(:delivery_id, :event_id, keyword_init: true)
200
+
201
+ # A signed-in session on the account. `current` is true for the session this
202
+ # client is using.
203
+ Session = Struct.new(:id, :created_at, :expires_at, :current, keyword_init: true) do
204
+ def self.from_wire(wire)
205
+ wire = Wire.object(wire)
206
+ new(id: Wire.text(wire["id"]), created_at: Wire.text(wire["createdAt"]),
207
+ expires_at: Wire.text(wire["expiresAt"]), current: Wire.flag(wire["current"], false))
208
+ end
209
+ end
210
+
211
+ # The account's subscription, balance, and any pause after a missed payment.
212
+ #
213
+ # `pending_plan_id` is the plan the account moves to when the paid month runs
214
+ # out. `balance_minor` is the account balance, in minor units. `pause` is
215
+ # what a missed payment has paused, and when it is deleted; nil when nothing
216
+ # is paused.
217
+ Subscription = Struct.new(:id, :plan_id, :status, :renewal_date, :pending_plan_id, :balance_minor, :pause,
218
+ keyword_init: true)
219
+
220
+ # The kind of name the server rolls for a new address.
221
+ module NameStyle
222
+ # Like `maya.holt`.
223
+ PERSON = "person"
224
+ # Like `harbor-supply42`.
225
+ BUSINESS = "business"
226
+ # Like `support_desk`.
227
+ TEAM = "team"
228
+ # Like `quiet_falcon`. What the server rolls when no style is given.
229
+ NEUTRAL = "neutral"
230
+ ALL = [PERSON, BUSINESS, TEAM, NEUTRAL].freeze
231
+
232
+ # The name the API uses, from a symbol or a string.
233
+ def self.wire(style)
234
+ name = style.to_s
235
+ unless ALL.include?(name)
236
+ raise ArgumentError,
237
+ "Unknown name style #{style.inspect}: use one of #{ALL.join(', ')}."
238
+ end
239
+
240
+ name
241
+ end
242
+ end
243
+ end