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.
- checksums.yaml +7 -0
- data/LICENSE +202 -0
- data/README.md +208 -0
- data/lib/mailcycle/client.rb +803 -0
- data/lib/mailcycle/crypto/asymmetric.rb +126 -0
- data/lib/mailcycle/crypto/auth_proof.rb +58 -0
- data/lib/mailcycle/crypto/cipher.rb +136 -0
- data/lib/mailcycle/crypto/identity.rb +170 -0
- data/lib/mailcycle/crypto/mnemonic.rb +126 -0
- data/lib/mailcycle/crypto/primitives.rb +116 -0
- data/lib/mailcycle/crypto/sealed_box.rb +93 -0
- data/lib/mailcycle/crypto/wordlist.rb +171 -0
- data/lib/mailcycle/encoding.rb +93 -0
- data/lib/mailcycle/errors.rb +82 -0
- data/lib/mailcycle/events.rb +310 -0
- data/lib/mailcycle/http.rb +113 -0
- data/lib/mailcycle/keys.rb +81 -0
- data/lib/mailcycle/mail.rb +143 -0
- data/lib/mailcycle/manage.rb +202 -0
- data/lib/mailcycle/models.rb +243 -0
- data/lib/mailcycle/pairing.rb +338 -0
- data/lib/mailcycle/trackers.rb +312 -0
- data/lib/mailcycle/version.rb +5 -0
- data/lib/mailcycle/webhook_signature.rb +60 -0
- data/lib/mailcycle/websocket.rb +320 -0
- data/lib/mailcycle.rb +109 -0
- metadata +83 -0
|
@@ -0,0 +1,803 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module Mailcycle
|
|
6
|
+
# A file to send with a message.
|
|
7
|
+
#
|
|
8
|
+
# `filename` is what the recipient saves it as; program and script files
|
|
9
|
+
# (.exe, .js, .ps1 and the like) are refused. `content` is the raw bytes.
|
|
10
|
+
# `mime_type` defaults to application/octet-stream. `content_id` makes the
|
|
11
|
+
# file inline, shown where the HTML says `cid:<content_id>`.
|
|
12
|
+
OutgoingAttachment = Struct.new(:filename, :content, :mime_type, :content_id, keyword_init: true)
|
|
13
|
+
|
|
14
|
+
# Records sealed under the vault key: address details, device profiles, and
|
|
15
|
+
# the names on keys and webhooks.
|
|
16
|
+
module Vault
|
|
17
|
+
ID_ALPHABET = "abcdefghijklmnopqrstuvwxyz0123456789"
|
|
18
|
+
|
|
19
|
+
module_function
|
|
20
|
+
|
|
21
|
+
def new_id(prefix, length)
|
|
22
|
+
out = +""
|
|
23
|
+
# Rejection sampling, so every character is equally likely. 252 is the
|
|
24
|
+
# largest multiple of 36 below 256.
|
|
25
|
+
while out.length < length
|
|
26
|
+
Crypto::Primitives.random_bytes(length * 2).each_byte do |byte|
|
|
27
|
+
break if out.length == length
|
|
28
|
+
|
|
29
|
+
out << ID_ALPHABET[byte % 36] if byte < 252
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
"#{prefix}_#{out}"
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# What a record sealed under the vault key is bound to.
|
|
36
|
+
#
|
|
37
|
+
# The kind of record as well as its id, so a box from one kind cannot be
|
|
38
|
+
# read as another. Records sealed before that change carry the bare id,
|
|
39
|
+
# and still open; `open_record` tries both.
|
|
40
|
+
def aad(kind, id)
|
|
41
|
+
"mailcycle/v1/#{kind}:#{id}"
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def seal(keys, kind, id, value)
|
|
45
|
+
Crypto::Cipher.seal_json(keys.vault_key, value, aad(kind, id)).to_wire
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def open_record(vault_key, kind, id, box)
|
|
49
|
+
Crypto::Cipher.open_json(vault_key, box, aad(kind, id))
|
|
50
|
+
rescue DecryptionError
|
|
51
|
+
Crypto::Cipher.open_json(vault_key, box, id)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def sealed_box?(value)
|
|
55
|
+
value.is_a?(Hash) && value.key?("c") && !value.key?("epk")
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# The record, opened, as a Hash; empty if there are no keys or it will not
|
|
59
|
+
# open.
|
|
60
|
+
def open_or_empty(keys, kind, id, value)
|
|
61
|
+
return {} if keys.nil? || !sealed_box?(value)
|
|
62
|
+
|
|
63
|
+
box = Crypto::SealedBox.from_wire(value)
|
|
64
|
+
return {} if box.nil?
|
|
65
|
+
|
|
66
|
+
Wire.object(open_record(keys.vault_key, kind, id, box))
|
|
67
|
+
rescue Error
|
|
68
|
+
{}
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# A client for one Mailcycle account.
|
|
73
|
+
#
|
|
74
|
+
# Signed in with the recovery phrase, it can do everything the app can: open
|
|
75
|
+
# mail, create addresses that receive it, and read the labels and names the
|
|
76
|
+
# app seals. With an API key alone it manages addresses, devices and
|
|
77
|
+
# metadata; add the phrase to open mail too.
|
|
78
|
+
#
|
|
79
|
+
# The phrase never leaves this machine, and nothing derived from it is sent
|
|
80
|
+
# anywhere. What reaches the API is an account id, a public key, and a proof
|
|
81
|
+
# that this machine holds the matching private one.
|
|
82
|
+
#
|
|
83
|
+
# Every constructor takes the same options: `api_url` (the live API by
|
|
84
|
+
# default), `passphrase` (empty unless the account was made with one) and
|
|
85
|
+
# `timeout` in seconds (30).
|
|
86
|
+
class Client
|
|
87
|
+
attr_reader :http, :keys
|
|
88
|
+
|
|
89
|
+
# A new twelve-word recovery phrase, generated on this machine.
|
|
90
|
+
def self.generate_phrase
|
|
91
|
+
Crypto::Mnemonic.generate.join(" ")
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Signs in with the recovery phrase.
|
|
95
|
+
#
|
|
96
|
+
# The same challenge-response the app uses, in which nothing secret
|
|
97
|
+
# crosses the wire. Sessions last 30 days; keep `session_token` and pass
|
|
98
|
+
# it to `resume` rather than deriving the keys again every run.
|
|
99
|
+
def self.sign_in(phrase, api_url: Http::DEFAULT_API_URL, passphrase: "", timeout: Http::DEFAULT_TIMEOUT_SECONDS)
|
|
100
|
+
keys = Keys.from_phrase(phrase, passphrase)
|
|
101
|
+
http = Http.new(api_url, nil, timeout)
|
|
102
|
+
|
|
103
|
+
challenge = Wire.object(http.request("POST", "/accounts/challenge", body: { "accountId" => keys.account_id }))
|
|
104
|
+
nonce = Wire.text(challenge["nonce"])
|
|
105
|
+
proof = Crypto::AuthProof.prove_account(keys.auth_key, Wire.text(challenge["challengeKey"]), nonce,
|
|
106
|
+
keys.account_id, Crypto::AuthProof::DOMAIN)
|
|
107
|
+
|
|
108
|
+
session = Wire.object(http.request("POST", "/accounts/verify",
|
|
109
|
+
body: { "accountId" => keys.account_id, "nonce" => nonce,
|
|
110
|
+
"proof" => proof }))
|
|
111
|
+
http.token = Wire.maybe_text(session["token"])
|
|
112
|
+
new(http, keys, api_key: false)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Picks up a session from an earlier `sign_in`.
|
|
116
|
+
#
|
|
117
|
+
# The phrase is still needed to open mail, and is checked against the
|
|
118
|
+
# session's account before it is used.
|
|
119
|
+
def self.resume(session_token, phrase, api_url: Http::DEFAULT_API_URL, passphrase: "",
|
|
120
|
+
timeout: Http::DEFAULT_TIMEOUT_SECONDS)
|
|
121
|
+
keys = Keys.from_phrase(phrase, passphrase)
|
|
122
|
+
client = new(Http.new(api_url, session_token, timeout), keys, api_key: false)
|
|
123
|
+
client.__send__(:check_phrase_matches, "session")
|
|
124
|
+
client
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Uses an API key, `mak_...`. Operator plan and up.
|
|
128
|
+
#
|
|
129
|
+
# With the phrase as well, the client can open mail and create addresses
|
|
130
|
+
# that receive it; the phrase is checked against the key's account first.
|
|
131
|
+
def self.with_api_key(api_key, phrase: nil, api_url: Http::DEFAULT_API_URL, passphrase: "",
|
|
132
|
+
timeout: Http::DEFAULT_TIMEOUT_SECONDS)
|
|
133
|
+
keys = phrase.nil? ? nil : Keys.from_phrase(phrase, passphrase)
|
|
134
|
+
client = new(Http.new(api_url, api_key, timeout), keys, api_key: true)
|
|
135
|
+
client.__send__(:check_phrase_matches, "API key") if keys
|
|
136
|
+
client
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Creates an account and signs in. Returns `[client, phrase]`.
|
|
140
|
+
#
|
|
141
|
+
# Keep the phrase. Nothing can recover the account without it, and nobody
|
|
142
|
+
# at Mailcycle can help.
|
|
143
|
+
def self.create_account(phrase = nil, api_url: Http::DEFAULT_API_URL, passphrase: "",
|
|
144
|
+
timeout: Http::DEFAULT_TIMEOUT_SECONDS)
|
|
145
|
+
words = phrase || generate_phrase
|
|
146
|
+
keys = Keys.from_phrase(words, passphrase)
|
|
147
|
+
http = Http.new(api_url, nil, timeout)
|
|
148
|
+
session = Wire.object(http.request("POST", "/accounts", body: {
|
|
149
|
+
"accountId" => keys.account_id,
|
|
150
|
+
"authVerifier" => keys.auth_verifier,
|
|
151
|
+
"authPublicKey" => keys.auth_public_key
|
|
152
|
+
}))
|
|
153
|
+
http.token = Wire.maybe_text(session["token"])
|
|
154
|
+
[new(http, keys, api_key: false), words]
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def initialize(http, keys, api_key:)
|
|
158
|
+
@http = http
|
|
159
|
+
@keys = keys
|
|
160
|
+
# Built with an API key rather than a session.
|
|
161
|
+
@api_key = api_key
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# -- the account -------------------------------------------------------
|
|
165
|
+
|
|
166
|
+
# The account id, when the phrase is known. Public, and safe to log.
|
|
167
|
+
def account_id
|
|
168
|
+
@keys&.account_id
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# The bearer this client uses: the session from `sign_in`, or the API key.
|
|
172
|
+
def session_token
|
|
173
|
+
@http.token
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# Whether this client can open mail and create addresses that receive it.
|
|
177
|
+
def can_decrypt?
|
|
178
|
+
!@keys.nil?
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def account
|
|
182
|
+
result = Wire.object(@http.request("GET", "/accounts/me"))
|
|
183
|
+
operator = Wire.object(result["operator"])
|
|
184
|
+
Account.new(
|
|
185
|
+
id: Wire.text(operator["id"]),
|
|
186
|
+
plan_id: Wire.text(operator["planId"]),
|
|
187
|
+
created_at: Wire.text(operator["createdAt"]),
|
|
188
|
+
plan: Plan.from_wire(result["plan"])
|
|
189
|
+
)
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# Recent account events, newest first: ids and times, never content.
|
|
193
|
+
def activity(limit = 20)
|
|
194
|
+
activity_page(limit: limit).activity
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# One page of account events, newest first.
|
|
198
|
+
#
|
|
199
|
+
# `cursor` is the `next_cursor` of the page before, passed back as it is.
|
|
200
|
+
# It is opaque: do not build one or read a time out of it.
|
|
201
|
+
def activity_page(limit: nil, cursor: nil)
|
|
202
|
+
query = []
|
|
203
|
+
query << ["limit", limit.to_s] if limit
|
|
204
|
+
query << ["cursor", cursor] if cursor
|
|
205
|
+
result = Wire.object(@http.request("GET", "/activity", query: query))
|
|
206
|
+
ActivityPage.new(activity: Wire.array(result["activity"]).map { |entry| ActivityEntry.from_wire(entry) },
|
|
207
|
+
next_cursor: Wire.maybe_text(result["nextCursor"]))
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# Every event the server still holds (the last 90 days), newest first,
|
|
211
|
+
# walking the pages.
|
|
212
|
+
def all_activity
|
|
213
|
+
out = []
|
|
214
|
+
cursor = nil
|
|
215
|
+
loop do
|
|
216
|
+
page = activity_page(limit: 200, cursor: cursor)
|
|
217
|
+
out.concat(page.activity)
|
|
218
|
+
cursor = page.next_cursor
|
|
219
|
+
return out if cursor.nil?
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
# What the account has used, against what its plan allows.
|
|
224
|
+
def usage
|
|
225
|
+
@http.request("GET", "/usage")
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
# Ends this session.
|
|
229
|
+
#
|
|
230
|
+
# Does nothing for a client built with an API key: a key is not a
|
|
231
|
+
# session, and it keeps working until it is revoked with
|
|
232
|
+
# `api_keys.revoke` or in the app.
|
|
233
|
+
def sign_out
|
|
234
|
+
return nil if @api_key
|
|
235
|
+
|
|
236
|
+
@http.request("POST", "/accounts/signout", body: {})
|
|
237
|
+
@http.token = nil
|
|
238
|
+
nil
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
# Erases the account and everything in it, at once. Session only.
|
|
242
|
+
#
|
|
243
|
+
# There is no undo and no grace period. Its addresses are retired and
|
|
244
|
+
# never issued again.
|
|
245
|
+
#
|
|
246
|
+
# It answers a fresh challenge with the phrase, so a copied session token
|
|
247
|
+
# alone cannot erase the account.
|
|
248
|
+
def delete_account
|
|
249
|
+
body = nil
|
|
250
|
+
if @keys && !@api_key
|
|
251
|
+
challenge = Wire.object(@http.request("POST", "/accounts/challenge",
|
|
252
|
+
body: { "accountId" => @keys.account_id }))
|
|
253
|
+
nonce = Wire.text(challenge["nonce"])
|
|
254
|
+
proof = Crypto::AuthProof.prove_account(@keys.auth_key, Wire.text(challenge["challengeKey"]), nonce,
|
|
255
|
+
@keys.account_id, Crypto::AuthProof::DELETE_DOMAIN)
|
|
256
|
+
body = { "nonce" => nonce, "proof" => proof }
|
|
257
|
+
end
|
|
258
|
+
@http.request("DELETE", "/accounts/me", body: body)
|
|
259
|
+
@http.token = nil
|
|
260
|
+
nil
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
# The account's subscription and balance.
|
|
264
|
+
def subscription
|
|
265
|
+
result = Wire.object(@http.request("GET", "/subscription"))
|
|
266
|
+
subscription = Wire.object(result["subscription"])
|
|
267
|
+
balance = result["balanceMinor"]
|
|
268
|
+
Subscription.new(
|
|
269
|
+
id: Wire.text(subscription["id"]),
|
|
270
|
+
plan_id: Wire.text(subscription["planId"]),
|
|
271
|
+
status: Wire.text(subscription["status"]),
|
|
272
|
+
renewal_date: Wire.text(subscription["renewalDate"]),
|
|
273
|
+
pending_plan_id: Wire.maybe_text(subscription["pendingPlanId"]),
|
|
274
|
+
balance_minor: balance.is_a?(Integer) ? balance : 0,
|
|
275
|
+
pause: result["pause"]
|
|
276
|
+
)
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
# Every plan, with what each includes and costs.
|
|
280
|
+
def plans
|
|
281
|
+
Wire.array(Wire.object(@http.request("GET", "/plans"))["plans"]).map { |plan| Plan.from_wire(plan) }
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
def addresses
|
|
285
|
+
Addresses.new(self)
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
def messages
|
|
289
|
+
Messages.new(self)
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
def devices
|
|
293
|
+
Devices.new(self)
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
def domains
|
|
297
|
+
Domains.new(self)
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
def events
|
|
301
|
+
Events.new(self)
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
# API keys. Session only.
|
|
305
|
+
def api_keys
|
|
306
|
+
ApiKeys.new(self)
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
# Webhooks, Operator and up.
|
|
310
|
+
def webhooks
|
|
311
|
+
Webhooks.new(self)
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
# The account's signed-in sessions. Session only.
|
|
315
|
+
def sessions
|
|
316
|
+
Sessions.new(self)
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
def require_keys(what)
|
|
320
|
+
return @keys if @keys
|
|
321
|
+
|
|
322
|
+
raise UnsupportedError, "The recovery phrase is needed to #{what}. Pass it when building the client."
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
def inspect
|
|
326
|
+
"#<Mailcycle::Client account_id=#{account_id.inspect} api_url=#{@http.base_url.inspect}>"
|
|
327
|
+
end
|
|
328
|
+
alias to_s inspect
|
|
329
|
+
|
|
330
|
+
private
|
|
331
|
+
|
|
332
|
+
def check_phrase_matches(what)
|
|
333
|
+
return if account.id == @keys.account_id
|
|
334
|
+
|
|
335
|
+
raise UnsupportedError, "That phrase belongs to a different account from the #{what}."
|
|
336
|
+
end
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
# Email addresses on the account.
|
|
340
|
+
class Addresses
|
|
341
|
+
UNCHANGED = Object.new.freeze
|
|
342
|
+
private_constant :UNCHANGED
|
|
343
|
+
|
|
344
|
+
def initialize(client)
|
|
345
|
+
@client = client
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
def list
|
|
349
|
+
result = Wire.object(@client.http.request("GET", "/inboxes"))
|
|
350
|
+
Wire.array(result["inboxes"]).map { |item| to_address(item) }
|
|
351
|
+
end
|
|
352
|
+
|
|
353
|
+
# Up to eight names in one style that are free on the domain right now.
|
|
354
|
+
#
|
|
355
|
+
# Pass one to `create` as `local_part`. Somebody may take it first, and
|
|
356
|
+
# then `create` is refused. `domain` defaults to the one `create` would
|
|
357
|
+
# use. `style` is one of NameStyle's, as a string or a symbol.
|
|
358
|
+
def roll_names(style, domain: nil)
|
|
359
|
+
wire_style = NameStyle.wire(style)
|
|
360
|
+
query = [["style", wire_style], ["domain", domain || default_domain]]
|
|
361
|
+
Wire.strings(Wire.object(@client.http.request("GET", "/inboxes/names", query: query))["names"])
|
|
362
|
+
end
|
|
363
|
+
|
|
364
|
+
# Creates an address that can receive mail.
|
|
365
|
+
#
|
|
366
|
+
# Needs the phrase: the address's public key is derived from it here, and
|
|
367
|
+
# only the public half is uploaded. Mail sealed to it opens nowhere else.
|
|
368
|
+
#
|
|
369
|
+
# - `domain`: from `domains.list`. Defaults to the first one available.
|
|
370
|
+
# - `prefix`: the start of the address; the server adds a random ending.
|
|
371
|
+
# - `local_part`: the whole part before the `@`. A name from `roll_names`
|
|
372
|
+
# works on every plan; any other name needs Scale and up.
|
|
373
|
+
# - `style`: the kind of name the server rolls, used only when neither
|
|
374
|
+
# `local_part` nor `prefix` is given. The server picks neutral by
|
|
375
|
+
# default.
|
|
376
|
+
# - `label`: a private label, sealed on this machine.
|
|
377
|
+
# - `retention_days`: 1 to 90. Defaults to 7.
|
|
378
|
+
def create(domain: nil, prefix: nil, local_part: nil, style: nil, label: nil, retention_days: nil)
|
|
379
|
+
keys = @client.require_keys("create an address that receives mail")
|
|
380
|
+
id = Vault.new_id("ibx", 12)
|
|
381
|
+
domain ||= default_domain
|
|
382
|
+
|
|
383
|
+
body = {
|
|
384
|
+
"id" => id,
|
|
385
|
+
"domain" => domain,
|
|
386
|
+
"meta" => Vault.seal(keys, "inbox", id, label.nil? ? {} : { "label" => label }),
|
|
387
|
+
# The public half only. Mail is sealed to it and opens only with the phrase.
|
|
388
|
+
"publicKey" => keys.address_public_key(id)
|
|
389
|
+
}
|
|
390
|
+
if local_part
|
|
391
|
+
body["localPart"] = local_part
|
|
392
|
+
elsif prefix
|
|
393
|
+
body["prefix"] = prefix
|
|
394
|
+
elsif style
|
|
395
|
+
body["style"] = NameStyle.wire(style)
|
|
396
|
+
end
|
|
397
|
+
body["retentionDays"] = retention_days if retention_days
|
|
398
|
+
|
|
399
|
+
to_address(Wire.object(@client.http.request("POST", "/inboxes", body: body))["inbox"])
|
|
400
|
+
end
|
|
401
|
+
|
|
402
|
+
# Changes a label, a retention window, or both. Leave a keyword out to
|
|
403
|
+
# leave that alone; a nil or empty label clears it.
|
|
404
|
+
def update(address_id, label: UNCHANGED, retention_days: nil)
|
|
405
|
+
body = {}
|
|
406
|
+
body["retentionDays"] = retention_days if retention_days
|
|
407
|
+
unless label.equal?(UNCHANGED)
|
|
408
|
+
keys = @client.require_keys("change a label")
|
|
409
|
+
value = label.nil? || label.empty? ? {} : { "label" => label }
|
|
410
|
+
body["meta"] = Vault.seal(keys, "inbox", address_id, value)
|
|
411
|
+
end
|
|
412
|
+
result = @client.http.request("PATCH", "/inboxes/#{Mailcycle.path_segment(address_id)}", body: body)
|
|
413
|
+
to_address(Wire.object(result)["inbox"])
|
|
414
|
+
end
|
|
415
|
+
|
|
416
|
+
# Deletes the address and its mail. It is never issued again.
|
|
417
|
+
def delete(address_id)
|
|
418
|
+
@client.http.request("DELETE", "/inboxes/#{Mailcycle.path_segment(address_id)}")
|
|
419
|
+
nil
|
|
420
|
+
end
|
|
421
|
+
|
|
422
|
+
# How many addresses the plan allows and how many are left.
|
|
423
|
+
def allocation
|
|
424
|
+
@client.http.request("GET", "/inboxes/allocation")
|
|
425
|
+
end
|
|
426
|
+
|
|
427
|
+
private
|
|
428
|
+
|
|
429
|
+
def to_address(wire)
|
|
430
|
+
wire = Wire.object(wire)
|
|
431
|
+
id = Wire.text(wire["id"])
|
|
432
|
+
meta = Vault.open_or_empty(@client.keys, "inbox", id, wire["meta"])
|
|
433
|
+
label = Wire.maybe_text(meta["label"])
|
|
434
|
+
sender_name = Wire.maybe_text(meta["senderName"])
|
|
435
|
+
Address.new(
|
|
436
|
+
id: id,
|
|
437
|
+
email_address: Wire.text(wire["emailAddress"]),
|
|
438
|
+
status: Wire.text(wire["status"]),
|
|
439
|
+
created_at: Wire.text(wire["createdAt"]),
|
|
440
|
+
retention_days: Wire.count(wire["retentionDays"]),
|
|
441
|
+
assigned_worker_id: Wire.maybe_text(wire["assignedWorkerId"]),
|
|
442
|
+
paused_at: Wire.maybe_text(wire["pausedAt"]),
|
|
443
|
+
label: label && label.empty? ? nil : label,
|
|
444
|
+
sender_name: sender_name && sender_name.empty? ? nil : sender_name,
|
|
445
|
+
receives: Wire.flag(wire["receives"], true)
|
|
446
|
+
)
|
|
447
|
+
end
|
|
448
|
+
|
|
449
|
+
# The first domain the account can create addresses on.
|
|
450
|
+
def default_domain
|
|
451
|
+
first = @client.domains.list.first
|
|
452
|
+
raise UnsupportedError, "The account has no domain to create addresses on." if first.nil?
|
|
453
|
+
|
|
454
|
+
first.domain
|
|
455
|
+
end
|
|
456
|
+
end
|
|
457
|
+
|
|
458
|
+
# Mail: list, read, organise, send, and wait for new mail.
|
|
459
|
+
class Messages
|
|
460
|
+
# How long `wait_for` gives the event stream to come up before it polls.
|
|
461
|
+
STREAM_OPEN_SECONDS = 10
|
|
462
|
+
# How far the server's clock may trail this one, in seconds, before mail
|
|
463
|
+
# received just after `since` is taken for older mail.
|
|
464
|
+
CLOCK_SKEW_SECONDS = 5
|
|
465
|
+
# The API's limits on what a message can carry. It is the authority; these
|
|
466
|
+
# only refuse early what it would refuse anyway.
|
|
467
|
+
MAX_ATTACHMENTS = 10
|
|
468
|
+
MAX_ATTACHMENT_BYTES = 7 * 1024 * 1024 / 2
|
|
469
|
+
|
|
470
|
+
def initialize(client)
|
|
471
|
+
@client = client
|
|
472
|
+
end
|
|
473
|
+
|
|
474
|
+
# One page of a folder, newest first.
|
|
475
|
+
#
|
|
476
|
+
# `cursor` is the `next_cursor` of the page before, passed back as it is.
|
|
477
|
+
# It is opaque: do not build one or read a time out of it.
|
|
478
|
+
def list(address_id, folder: nil, limit: nil, cursor: nil)
|
|
479
|
+
query = []
|
|
480
|
+
query << ["folder", folder] if folder
|
|
481
|
+
query << ["limit", limit.to_s] if limit
|
|
482
|
+
query << ["cursor", cursor] if cursor
|
|
483
|
+
path = "/inboxes/#{Mailcycle.path_segment(address_id)}/messages"
|
|
484
|
+
result = Wire.object(@client.http.request("GET", path, query: query))
|
|
485
|
+
MessagePage.new(messages: Wire.array(result["messages"]).map { |item| Mail.to_message(@client.keys, item) },
|
|
486
|
+
next_cursor: Wire.maybe_text(result["nextCursor"]))
|
|
487
|
+
end
|
|
488
|
+
|
|
489
|
+
# Every message in a folder, walking the pages.
|
|
490
|
+
def all(address_id, folder: nil)
|
|
491
|
+
out = []
|
|
492
|
+
cursor = nil
|
|
493
|
+
loop do
|
|
494
|
+
page = list(address_id, folder: folder, limit: 100, cursor: cursor)
|
|
495
|
+
out.concat(page.messages)
|
|
496
|
+
cursor = page.next_cursor
|
|
497
|
+
return out if cursor.nil?
|
|
498
|
+
end
|
|
499
|
+
end
|
|
500
|
+
|
|
501
|
+
def get(message_id)
|
|
502
|
+
result = @client.http.request("GET", "/messages/#{Mailcycle.path_segment(message_id)}")
|
|
503
|
+
Mail.to_message(@client.keys, Wire.object(result)["message"])
|
|
504
|
+
end
|
|
505
|
+
|
|
506
|
+
# The attachment's bytes, opened on this machine. Needs the phrase.
|
|
507
|
+
def attachment(message, index)
|
|
508
|
+
keys = @client.require_keys("open an attachment")
|
|
509
|
+
path = "/messages/#{Mailcycle.path_segment(message.id)}/attachments/#{Integer(index)}"
|
|
510
|
+
data = @client.http.request_bytes("GET", path)
|
|
511
|
+
Mail.open_attachment_bytes(keys, message.address_id, message.id, index, data, epoch: message.key_epoch)
|
|
512
|
+
end
|
|
513
|
+
|
|
514
|
+
def mark_read(message_id, read = true) # rubocop:disable Style/OptionalBooleanParameter
|
|
515
|
+
post(message_id, "read", { "read" => read })
|
|
516
|
+
end
|
|
517
|
+
|
|
518
|
+
def star(message_id, starred = true) # rubocop:disable Style/OptionalBooleanParameter
|
|
519
|
+
post(message_id, "star", { "starred" => starred })
|
|
520
|
+
end
|
|
521
|
+
|
|
522
|
+
# Moves to `inbox`, `junk` or `trash`, or `restore` to put it back.
|
|
523
|
+
def move_to(message_id, folder)
|
|
524
|
+
post(message_id, "move", { "folder" => folder })
|
|
525
|
+
end
|
|
526
|
+
|
|
527
|
+
def delete(message_id)
|
|
528
|
+
@client.http.request("DELETE", "/messages/#{Mailcycle.path_segment(message_id)}")
|
|
529
|
+
nil
|
|
530
|
+
end
|
|
531
|
+
|
|
532
|
+
def empty_trash
|
|
533
|
+
Wire.count(Wire.object(@client.http.request("POST", "/messages/empty-trash", body: {}))["deleted"])
|
|
534
|
+
end
|
|
535
|
+
|
|
536
|
+
# Sends mail from an address on this account.
|
|
537
|
+
#
|
|
538
|
+
# `reply_to` is a Message to reply to, and the threading headers are
|
|
539
|
+
# filled in from it. `attachments` are up to ten OutgoingAttachment, 3.5 MB
|
|
540
|
+
# between them.
|
|
541
|
+
#
|
|
542
|
+
# Returns a SendResult: the Message-ID the sending service gave it, and
|
|
543
|
+
# the id of the copy kept in Sent.
|
|
544
|
+
def send(from:, to:, subject:, text:, html: nil, from_name: nil, reply_to: nil, attachments: [])
|
|
545
|
+
body = { "from" => from, "to" => to, "subject" => subject, "text" => text }
|
|
546
|
+
body["html"] = html if html
|
|
547
|
+
body["fromName"] = from_name if from_name
|
|
548
|
+
if reply_to
|
|
549
|
+
body["inReplyTo"] = reply_to.id
|
|
550
|
+
body["inReplyToHeader"] = reply_to.message_id unless reply_to.message_id.to_s.empty?
|
|
551
|
+
end
|
|
552
|
+
body["attachments"] = attachments_body(attachments) unless attachments.empty?
|
|
553
|
+
result = Wire.object(@client.http.request("POST", "/messages/send", body: body))
|
|
554
|
+
SendResult.new(message_id: Wire.text(result["messageId"]), sent_copy_id: Wire.maybe_text(result["sentCopyId"]))
|
|
555
|
+
end
|
|
556
|
+
|
|
557
|
+
# The next message that arrives and matches, opened.
|
|
558
|
+
#
|
|
559
|
+
# - `address_id`: only mail to this address. Needed when the stream is
|
|
560
|
+
# unavailable.
|
|
561
|
+
# - `from`: only mail whose From contains this, case-insensitive.
|
|
562
|
+
# - `subject`: only mail whose subject contains this, case-insensitive.
|
|
563
|
+
# - `timeout`, `poll_every` and `stream_open` are seconds: how long to
|
|
564
|
+
# wait in all, how often to check when polling, and how long to give the
|
|
565
|
+
# stream to come up before polling instead.
|
|
566
|
+
# - `since`: counts mail received from this time on, including mail that
|
|
567
|
+
# arrived before the stream was up. Defaults to when `wait_for` is
|
|
568
|
+
# called; set it to just before a send to wait for the reply. Five
|
|
569
|
+
# seconds are allowed for the difference between this clock and the
|
|
570
|
+
# server's.
|
|
571
|
+
#
|
|
572
|
+
# Listens on the event stream, and polls `address_id` where the stream
|
|
573
|
+
# will not open. Raises an ApiError with code `timeout` if nothing arrives
|
|
574
|
+
# in time.
|
|
575
|
+
def wait_for(address_id: nil, from: nil, subject: nil, timeout: 60, poll_every: 5,
|
|
576
|
+
stream_open: STREAM_OPEN_SECONDS, since: nil)
|
|
577
|
+
wanted = { address_id: address_id, from: from, subject: subject, since: since || Time.now }
|
|
578
|
+
deadline = monotonic + timeout
|
|
579
|
+
|
|
580
|
+
found = wait_on_stream(wanted, deadline, stream_open)
|
|
581
|
+
return found if found
|
|
582
|
+
raise ApiError.new(0, "timeout", "No matching mail arrived in time.") if monotonic >= deadline
|
|
583
|
+
|
|
584
|
+
wait_by_polling(wanted, deadline, poll_every)
|
|
585
|
+
end
|
|
586
|
+
|
|
587
|
+
private
|
|
588
|
+
|
|
589
|
+
def post(message_id, action, body)
|
|
590
|
+
@client.http.request("POST", "/messages/#{Mailcycle.path_segment(message_id)}/#{action}", body: body)
|
|
591
|
+
nil
|
|
592
|
+
end
|
|
593
|
+
|
|
594
|
+
def attachments_body(attachments)
|
|
595
|
+
if attachments.length > MAX_ATTACHMENTS
|
|
596
|
+
raise ApiError.new(0, "too_many_attachments",
|
|
597
|
+
"A message can have at most #{MAX_ATTACHMENTS} attachments.")
|
|
598
|
+
end
|
|
599
|
+
files = attachments.map { |file| file.is_a?(Hash) ? OutgoingAttachment.new(**file) : file }
|
|
600
|
+
if files.sum { |file| file.content.to_s.bytesize } > MAX_ATTACHMENT_BYTES
|
|
601
|
+
raise ApiError.new(0, "attachments_too_large", "Attachments can add up to 3.5 MB per message.")
|
|
602
|
+
end
|
|
603
|
+
|
|
604
|
+
files.map do |file|
|
|
605
|
+
item = { "filename" => file.filename, "content" => Encoding.to_b64(file.content.to_s) }
|
|
606
|
+
item["mimeType"] = file.mime_type if file.mime_type
|
|
607
|
+
item["contentId"] = file.content_id if file.content_id
|
|
608
|
+
item
|
|
609
|
+
end
|
|
610
|
+
end
|
|
611
|
+
|
|
612
|
+
def monotonic
|
|
613
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
614
|
+
end
|
|
615
|
+
|
|
616
|
+
def matches?(wanted, message)
|
|
617
|
+
return false if wanted[:address_id] && message.address_id != wanted[:address_id]
|
|
618
|
+
if wanted[:from] && !"#{message.from_name} #{message.from_address}".downcase.include?(wanted[:from].downcase)
|
|
619
|
+
return false
|
|
620
|
+
end
|
|
621
|
+
return false if wanted[:subject] && !message.subject.downcase.include?(wanted[:subject].downcase)
|
|
622
|
+
|
|
623
|
+
true
|
|
624
|
+
end
|
|
625
|
+
|
|
626
|
+
# Whether the message arrived at or after `since`, less the clock skew.
|
|
627
|
+
def received_since?(message, since)
|
|
628
|
+
Time.iso8601(message.received_at.to_s) >= since - CLOCK_SKEW_SECONDS
|
|
629
|
+
rescue ArgumentError
|
|
630
|
+
false
|
|
631
|
+
end
|
|
632
|
+
|
|
633
|
+
# A message that arrived since the wait's `since` and matches.
|
|
634
|
+
def fresh?(wanted, message)
|
|
635
|
+
received_since?(message, wanted[:since]) && matches?(wanted, message)
|
|
636
|
+
end
|
|
637
|
+
|
|
638
|
+
# Watches the event stream until the deadline, or gives up early.
|
|
639
|
+
#
|
|
640
|
+
# nil means "not on the stream": either it was never available, or the
|
|
641
|
+
# server ended it for good, in which case the caller falls back to polling
|
|
642
|
+
# rather than failing. Only the deadline is fatal.
|
|
643
|
+
def wait_on_stream(wanted, deadline, stream_open)
|
|
644
|
+
begin
|
|
645
|
+
stream = @client.events.stream
|
|
646
|
+
rescue Error
|
|
647
|
+
return nil
|
|
648
|
+
end
|
|
649
|
+
|
|
650
|
+
begin
|
|
651
|
+
# Connecting is retried in the background forever, so a stream that
|
|
652
|
+
# cannot open is indistinguishable from a quiet one. Give it a few
|
|
653
|
+
# seconds and then go and poll instead.
|
|
654
|
+
opening = [stream_open, deadline - monotonic].min
|
|
655
|
+
return nil if opening <= 0 || !stream.wait_until_open(opening) || stream.ended?
|
|
656
|
+
|
|
657
|
+
# Mail that landed before the stream was up raised no event this
|
|
658
|
+
# stream saw, so look for it once now.
|
|
659
|
+
if wanted[:address_id]
|
|
660
|
+
begin
|
|
661
|
+
found = list(wanted[:address_id], limit: 50).messages.find { |message| fresh?(wanted, message) }
|
|
662
|
+
return found if found
|
|
663
|
+
rescue Error
|
|
664
|
+
nil
|
|
665
|
+
end
|
|
666
|
+
end
|
|
667
|
+
|
|
668
|
+
loop do
|
|
669
|
+
remaining = deadline - monotonic
|
|
670
|
+
return nil if remaining <= 0
|
|
671
|
+
|
|
672
|
+
# nil is the deadline, or a stream that ended and will not come back.
|
|
673
|
+
event = stream.next_event(timeout: remaining)
|
|
674
|
+
return nil if event.nil?
|
|
675
|
+
next unless event.event_type == "message.received"
|
|
676
|
+
next if wanted[:address_id] && event.payload["inboxId"] != wanted[:address_id]
|
|
677
|
+
|
|
678
|
+
begin
|
|
679
|
+
message = get(Wire.text(event.payload["messageId"]))
|
|
680
|
+
return message if matches?(wanted, message)
|
|
681
|
+
rescue Error
|
|
682
|
+
# A message that would not fetch is not a reason to give up on the
|
|
683
|
+
# wait; polling is the documented fallback.
|
|
684
|
+
next
|
|
685
|
+
end
|
|
686
|
+
end
|
|
687
|
+
ensure
|
|
688
|
+
stream.close
|
|
689
|
+
end
|
|
690
|
+
end
|
|
691
|
+
|
|
692
|
+
def wait_by_polling(wanted, deadline, poll_every)
|
|
693
|
+
address_id = wanted[:address_id]
|
|
694
|
+
if address_id.nil?
|
|
695
|
+
raise UnsupportedError,
|
|
696
|
+
"The event stream is not available here. Pass address_id to wait by polling instead."
|
|
697
|
+
end
|
|
698
|
+
|
|
699
|
+
# One look back, for mail that landed between `since` and now.
|
|
700
|
+
first = list(address_id, limit: 50).messages
|
|
701
|
+
found = first.find { |message| fresh?(wanted, message) }
|
|
702
|
+
return found if found
|
|
703
|
+
|
|
704
|
+
# Then only what is newer than the newest seen: each message read is
|
|
705
|
+
# metered, and asking for the page again every few seconds spent a small
|
|
706
|
+
# plan's hourly reads in minutes while nothing arrived.
|
|
707
|
+
top = first.first
|
|
708
|
+
newest = top && "#{top.received_at}~#{top.id}"
|
|
709
|
+
loop do
|
|
710
|
+
remaining = deadline - monotonic
|
|
711
|
+
raise ApiError.new(0, "timeout", "No matching mail arrived in time.") if remaining <= 0
|
|
712
|
+
|
|
713
|
+
sleep([poll_every, remaining].min)
|
|
714
|
+
query = [["inboxIds", address_id], %w[folder inbox], %w[limit 50]]
|
|
715
|
+
query << ["newer", newest] if newest
|
|
716
|
+
result = Wire.object(@client.http.request("GET", "/messages", query: query))
|
|
717
|
+
arrived = Wire.array(result["messages"]).map { |item| Mail.to_message(@client.keys, item) }
|
|
718
|
+
next if arrived.empty?
|
|
719
|
+
|
|
720
|
+
newest = "#{arrived.first.received_at}~#{arrived.first.id}"
|
|
721
|
+
# Oldest first, so the first match is the first to arrive.
|
|
722
|
+
found = arrived.reverse.find { |message| matches?(wanted, message) }
|
|
723
|
+
return found if found
|
|
724
|
+
end
|
|
725
|
+
end
|
|
726
|
+
end
|
|
727
|
+
|
|
728
|
+
# Paired devices and AI agents. Pairing and handing over keys are in
|
|
729
|
+
# pairing.rb.
|
|
730
|
+
class Devices
|
|
731
|
+
def initialize(client)
|
|
732
|
+
@client = client
|
|
733
|
+
end
|
|
734
|
+
|
|
735
|
+
def list
|
|
736
|
+
out = []
|
|
737
|
+
cursor = nil
|
|
738
|
+
loop do
|
|
739
|
+
query = [%w[limit 200]]
|
|
740
|
+
query << ["cursor", cursor] if cursor
|
|
741
|
+
page = Wire.object(@client.http.request("GET", "/workers", query: query))
|
|
742
|
+
out.concat(Wire.array(page["workers"]).map { |item| to_device(item) })
|
|
743
|
+
cursor = Wire.maybe_text(page["nextCursor"])
|
|
744
|
+
return out if cursor.nil?
|
|
745
|
+
end
|
|
746
|
+
end
|
|
747
|
+
|
|
748
|
+
def get(device_id)
|
|
749
|
+
to_device(Wire.object(@client.http.request("GET", worker_path(device_id)))["worker"])
|
|
750
|
+
end
|
|
751
|
+
|
|
752
|
+
private
|
|
753
|
+
|
|
754
|
+
def to_device(wire)
|
|
755
|
+
wire = Wire.object(wire)
|
|
756
|
+
id = Wire.text(wire["id"])
|
|
757
|
+
profile = Vault.open_or_empty(@client.keys, Pairing::WORKER_RECORD, id, wire["profile"])
|
|
758
|
+
Device.new(
|
|
759
|
+
id: id,
|
|
760
|
+
platform: Wire.text(wire["platform"]),
|
|
761
|
+
kind: Wire.text(wire["kind"], "device"),
|
|
762
|
+
send_mode: Wire.text(wire["sendMode"], "full"),
|
|
763
|
+
status: Wire.text(wire["status"]),
|
|
764
|
+
created_at: Wire.text(wire["createdAt"]),
|
|
765
|
+
last_active_at: Wire.maybe_text(wire["lastActiveAt"]),
|
|
766
|
+
paused_at: Wire.maybe_text(wire["pausedAt"]),
|
|
767
|
+
name: Wire.maybe_text(profile["name"]),
|
|
768
|
+
tags: Wire.strings(profile["tags"])
|
|
769
|
+
)
|
|
770
|
+
end
|
|
771
|
+
|
|
772
|
+
def worker_path(device_id)
|
|
773
|
+
"/workers/#{Mailcycle.path_segment(device_id)}"
|
|
774
|
+
end
|
|
775
|
+
end
|
|
776
|
+
|
|
777
|
+
# Domains the account can create addresses on.
|
|
778
|
+
class Domains
|
|
779
|
+
def initialize(client)
|
|
780
|
+
@client = client
|
|
781
|
+
end
|
|
782
|
+
|
|
783
|
+
def list
|
|
784
|
+
Wire.array(Wire.object(@client.http.request("GET", "/domains"))["domains"]).map { |d| Domain.from_wire(d) }
|
|
785
|
+
end
|
|
786
|
+
end
|
|
787
|
+
|
|
788
|
+
# The live event stream.
|
|
789
|
+
class Events
|
|
790
|
+
def initialize(client)
|
|
791
|
+
@client = client
|
|
792
|
+
end
|
|
793
|
+
|
|
794
|
+
# Every account event as it happens. Sessions on any plan; API keys on
|
|
795
|
+
# Operator and up. See EventStream for the hooks.
|
|
796
|
+
def stream(on_reconnect: nil, on_error: nil)
|
|
797
|
+
token = @client.session_token
|
|
798
|
+
raise UnsupportedError, "Sign in first." if token.nil?
|
|
799
|
+
|
|
800
|
+
EventStream.new(@client.http.base_url, token, on_reconnect: on_reconnect, on_error: on_error)
|
|
801
|
+
end
|
|
802
|
+
end
|
|
803
|
+
end
|