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