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,310 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Mailcycle
6
+ # The account event stream, `GET /accounts/live`.
7
+ #
8
+ # Receive only. A background thread holds the socket and reconnects with
9
+ # backoff after a drop, saying so through `on_reconnect`, because events that
10
+ # happen while it is down are NOT replayed. It stops for good when the server
11
+ # ends it for a reason reconnecting cannot fix: the sign-in is gone (4001),
12
+ # the plan does not include the stream (4003), or the upgrade itself was
13
+ # refused.
14
+ #
15
+ # stream = mc.events.stream
16
+ # stream.each { |event| puts event.event_type }
17
+ #
18
+ # A refused stream is accepted and then sent an error frame at once, so the
19
+ # socket coming up proves nothing on its own. The stream counts as open once
20
+ # an event arrives or a second passes without a refusal, which is what
21
+ # `wait_until_open` waits for.
22
+ class EventStream
23
+ include Enumerable
24
+
25
+ PROTOCOL = "mailcycle.v1"
26
+ PING_SECONDS = 4 * 60
27
+ OPEN_GRACE_SECONDS = 1.0
28
+ MAX_RETRY_SECONDS = 60
29
+ # Closing codes reconnecting cannot fix.
30
+ FINAL_CODES = [4001, 4003].freeze
31
+ # The same, for an upgrade the server refused outright.
32
+ FINAL_STATUSES = [400, 401, 403, 404, 426].freeze
33
+ # How often the reading loop looks up to notice `close`, the grace and the
34
+ # ping.
35
+ TICK = 0.2
36
+
37
+ # Opens the stream in the background and returns at once.
38
+ #
39
+ # `on_reconnect` is called on the stream's thread each time it comes back
40
+ # after a drop; events that happen while it is down are not replayed, so
41
+ # it is where to refetch whatever the program is showing. `on_error` is
42
+ # called with the API's code and message when the stream is refused.
43
+ def initialize(base_url, token, on_reconnect: nil, on_error: nil)
44
+ @url = "#{base_url.to_s.sub('https://', 'wss://').sub('http://', 'ws://').sub(%r{/+\z}, '')}/accounts/live"
45
+ @token = token
46
+ @on_reconnect = on_reconnect
47
+ @on_error = on_error
48
+
49
+ @lock = Mutex.new
50
+ @changed = ConditionVariable.new
51
+ @events = []
52
+ @opened = false
53
+ @closed_reason = nil
54
+ @stopping = false
55
+ @socket = nil
56
+ @thread = Thread.new { run }
57
+ @thread.name = "mailcycle-events" if @thread.respond_to?(:name=)
58
+ @thread.report_on_exception = false
59
+ end
60
+
61
+ # The next event, or nil if the stream ends or `timeout` seconds run out.
62
+ #
63
+ # Blocking forever (no timeout) is right for a long-lived listener and
64
+ # wrong for anything with a deadline: a stream that is healthy and silent
65
+ # would never return. Pass a timeout for the second case.
66
+ def next_event(timeout: nil)
67
+ deadline = timeout && (monotonic + timeout)
68
+ @lock.synchronize do
69
+ loop do
70
+ # Anything already queued is handed out before saying it is over.
71
+ return @events.shift unless @events.empty?
72
+ return nil if @closed_reason
73
+
74
+ remaining = deadline && (deadline - monotonic)
75
+ return nil if remaining && remaining <= 0
76
+
77
+ @changed.wait(@lock, remaining)
78
+ end
79
+ end
80
+ end
81
+
82
+ # Every event, as it arrives, until the stream ends.
83
+ def each
84
+ return enum_for(:each) unless block_given?
85
+
86
+ while (event = next_event)
87
+ yield event
88
+ end
89
+ end
90
+
91
+ # Blocks until the stream is open, or it ends, or `timeout` seconds pass.
92
+ # True only for a stream that is open and was not refused.
93
+ #
94
+ # Connecting is retried in the background forever, which is right for a
95
+ # long-lived listener and wrong for a caller with something else it could
96
+ # be doing: without this, a stream that never connects looks exactly like
97
+ # a stream with nothing to say.
98
+ def wait_until_open(timeout)
99
+ deadline = monotonic + timeout
100
+ @lock.synchronize do
101
+ until @opened
102
+ remaining = deadline - monotonic
103
+ return false if remaining <= 0
104
+
105
+ @changed.wait(@lock, remaining)
106
+ end
107
+ @closed_reason.nil?
108
+ end
109
+ end
110
+
111
+ # True once the stream has stopped for good and will not reconnect.
112
+ def ended?
113
+ @lock.synchronize { !@closed_reason.nil? }
114
+ end
115
+
116
+ # Why it ended, once it has: `[code, reason]`, or nil while it runs.
117
+ def closed_reason
118
+ @lock.synchronize { @closed_reason&.dup }
119
+ end
120
+
121
+ # Stops listening and closes the socket. Safe to call twice.
122
+ def close
123
+ socket = @lock.synchronize do
124
+ @stopping = true
125
+ @changed.broadcast
126
+ @socket
127
+ end
128
+ socket&.close
129
+ # A connection attempt in flight can take a while to give up; the
130
+ # thread stops on its own once it does, so this does not wait for it.
131
+ @thread.join(5) unless Thread.current == @thread
132
+ finish(1000, "closed")
133
+ nil
134
+ end
135
+
136
+ private
137
+
138
+ def monotonic
139
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
140
+ end
141
+
142
+ def stopping?
143
+ @lock.synchronize { @stopping }
144
+ end
145
+
146
+ def finish(code, reason)
147
+ @lock.synchronize do
148
+ @closed_reason ||= [code, reason]
149
+ # Anything waiting for the socket to come up is waiting for something
150
+ # that is not going to happen now.
151
+ @opened = true
152
+ @changed.broadcast
153
+ end
154
+ end
155
+
156
+ def mark_open
157
+ @lock.synchronize do
158
+ @opened = true
159
+ @changed.broadcast
160
+ end
161
+ end
162
+
163
+ def push(event)
164
+ @lock.synchronize do
165
+ @events << event
166
+ @changed.broadcast
167
+ end
168
+ end
169
+
170
+ def run
171
+ retry_count = 0
172
+ ever_opened = false
173
+
174
+ until stopping?
175
+ begin
176
+ # The sign-in travels as the second subprotocol, which is what the
177
+ # server reads it from; both are plain HTTP tokens.
178
+ socket = WebSocket.connect(@url, protocols: [PROTOCOL, @token],
179
+ headers: { "User-Agent" => Http::USER_AGENT })
180
+ rescue WebSocket::Refused => e
181
+ # The upgrade itself was refused, which is how the API says the
182
+ # sign-in is wrong or the plan does not include the stream.
183
+ # Retrying that is pointless.
184
+ return finish(1006, e.message) if FINAL_STATUSES.include?(e.status)
185
+
186
+ retry_count = backoff(retry_count)
187
+ next
188
+ rescue StandardError
189
+ # A refused connection, a DNS failure, a TLS failure. Worth
190
+ # retrying, because all three are often temporary.
191
+ retry_count = backoff(retry_count)
192
+ next
193
+ end
194
+
195
+ @lock.synchronize { @socket = socket }
196
+ up = lambda do
197
+ @on_reconnect&.call if ever_opened
198
+ ever_opened = true
199
+ retry_count = 0
200
+ mark_open
201
+ end
202
+ code, reason, refused = pump(socket, up)
203
+ @lock.synchronize { @socket = nil }
204
+ socket.close
205
+
206
+ return finish(1000, "closed") if stopping?
207
+ return finish(code, refused) if refused
208
+ return finish(code, reason.to_s.empty? ? "closed" : reason) if FINAL_CODES.include?(code)
209
+
210
+ retry_count = backoff(retry_count)
211
+ end
212
+ finish(1000, "closed")
213
+ ensure
214
+ # A hook that raised ends the thread; whoever is waiting must hear so.
215
+ finish(1006, "the stream stopped unexpectedly")
216
+ end
217
+
218
+ # Reads one connection until it ends: `[code, reason, refused]`, where
219
+ # `refused` is the API's code when it sent an error frame, which
220
+ # reconnecting cannot fix. `opened` is called once the connection has proved
221
+ # it was not refused.
222
+ def pump(socket, opened)
223
+ grace = monotonic + OPEN_GRACE_SECONDS
224
+ next_ping = monotonic + PING_SECONDS
225
+ confirmed = false
226
+ confirm = lambda do
227
+ next if confirmed
228
+
229
+ confirmed = true
230
+ opened.call
231
+ end
232
+
233
+ loop do
234
+ return [1000, "closed", nil] if stopping?
235
+
236
+ confirm.call if !confirmed && monotonic >= grace
237
+ if monotonic >= next_ping
238
+ socket.send_text("ping")
239
+ next_ping = monotonic + PING_SECONDS
240
+ end
241
+
242
+ frame = socket.read(timeout: TICK)
243
+ next if frame.nil?
244
+ return [frame.code, frame.reason, nil] if frame.type == :close
245
+ next unless frame.type == :text
246
+
247
+ refused = handle_frame(frame.data)
248
+ if refused
249
+ # Wait for the close that follows, so `closed_reason` reports the
250
+ # server's 4001 or 4003 rather than a lost connection.
251
+ return [closing_code(socket), refused, refused]
252
+ end
253
+
254
+ confirm.call
255
+ end
256
+ rescue WebSocket::Broken, IOError, SystemCallError
257
+ [1006, "connection lost", nil]
258
+ end
259
+
260
+ def closing_code(socket)
261
+ deadline = monotonic + 2
262
+ while monotonic < deadline && !stopping?
263
+ frame = socket.read(timeout: TICK)
264
+ return frame.code if frame&.type == :close
265
+ end
266
+ 1006
267
+ rescue WebSocket::Broken, IOError, SystemCallError
268
+ 1006
269
+ end
270
+
271
+ # Queues an event, or returns the API's code for a refusal.
272
+ def handle_frame(text)
273
+ return nil if text == "pong"
274
+
275
+ begin
276
+ frame = JSON.parse(text)
277
+ rescue JSON::ParserError
278
+ return nil
279
+ end
280
+ return nil unless frame.is_a?(Hash)
281
+
282
+ if frame["type"] == "error"
283
+ code = frame["code"].is_a?(String) ? frame["code"] : "error"
284
+ message = frame["message"].is_a?(String) ? frame["message"] : "The event stream was refused."
285
+ @on_error&.call(code, message)
286
+ return code
287
+ end
288
+
289
+ event = AccountEvent.from_wire(frame)
290
+ push(event) if event
291
+ nil
292
+ end
293
+
294
+ # Waits before the next attempt, waking early for `close`. Returns the
295
+ # next retry count.
296
+ def backoff(retry_count)
297
+ seconds = [MAX_RETRY_SECONDS, 2**[retry_count, 6].min].min
298
+ deadline = monotonic + seconds
299
+ @lock.synchronize do
300
+ until @stopping
301
+ remaining = deadline - monotonic
302
+ break if remaining <= 0
303
+
304
+ @changed.wait(@lock, remaining)
305
+ end
306
+ end
307
+ [retry_count + 1, 8].min
308
+ end
309
+ end
310
+ end
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "net/http"
5
+ require "openssl"
6
+ require "uri"
7
+
8
+ module Mailcycle
9
+ # One HTTP wrapper. Every failure comes out as an ApiError with the API's own
10
+ # code, so a caller matches on the code rather than on a message.
11
+ class Http
12
+ DEFAULT_API_URL = "https://api.mailcycle.email"
13
+ DEFAULT_TIMEOUT_SECONDS = 30
14
+ USER_AGENT = "mailcycle-ruby/#{VERSION}".freeze
15
+
16
+ # What failing to reach the API looks like from Net::HTTP.
17
+ NETWORK_ERRORS = [
18
+ SystemCallError, SocketError, IOError, EOFError, Timeout::Error,
19
+ OpenSSL::SSL::SSLError, Net::HTTPBadResponse, Net::ProtocolError
20
+ ].freeze
21
+
22
+ attr_reader :base_url, :timeout
23
+ attr_accessor :token
24
+
25
+ def initialize(base_url, token, timeout)
26
+ @base_url = base_url.to_s.sub(%r{/+\z}, "")
27
+ @token = token
28
+ @timeout = timeout
29
+ end
30
+
31
+ # JSON in and out. Returns the parsed answer, or nil for an empty one.
32
+ def request(method, path, body: nil, query: nil)
33
+ response = perform(method, path, body, query)
34
+ text = response.body.to_s
35
+ return nil if text.strip.empty?
36
+
37
+ JSON.parse(text.dup.force_encoding(::Encoding::UTF_8))
38
+ rescue JSON::ParserError, ::EncodingError
39
+ raise ApiError.new(0, "bad_response", "The API returned something unexpected.")
40
+ end
41
+
42
+ # Raw bytes back instead of JSON, for an attachment body.
43
+ def request_bytes(method, path)
44
+ perform(method, path, nil, nil).body.to_s.b
45
+ end
46
+
47
+ private
48
+
49
+ def perform(method, path, body, query)
50
+ uri = URI.parse("#{@base_url}#{path}")
51
+ uri.query = URI.encode_www_form(query) if query && !query.empty?
52
+ request = build(method, uri, body)
53
+
54
+ response = begin
55
+ Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https", open_timeout: @timeout,
56
+ read_timeout: @timeout, write_timeout: @timeout) do |http|
57
+ http.request(request)
58
+ end
59
+ rescue *NETWORK_ERRORS => e
60
+ raise ApiError.new(0, "network_error", "Could not reach #{@base_url}: #{e.message}")
61
+ end
62
+ finish(response)
63
+ end
64
+
65
+ def build(method, uri, body)
66
+ request = Net::HTTPGenericRequest.new(method, method != "GET" && !body.nil?, true, uri)
67
+ request["Accept"] = "application/json"
68
+ request["User-Agent"] = USER_AGENT
69
+ request["Authorization"] = "Bearer #{@token}" if @token
70
+ if method != "GET" && !body.nil?
71
+ request["Content-Type"] = "application/json"
72
+ request.body = JSON.generate(body)
73
+ end
74
+ request
75
+ end
76
+
77
+ def finish(response)
78
+ status = response.code.to_i
79
+ return response if status.between?(200, 299)
80
+
81
+ # Redirects are not followed, so the bearer can never be replayed to
82
+ # another host. One that arrives anyway is a failure, not an empty body.
83
+ if status.between?(300, 399)
84
+ raise ApiError.new(status, "unexpected_redirect", "#{@base_url} redirected the request, which is not followed.")
85
+ end
86
+
87
+ code = "http_error"
88
+ message = "HTTP #{status}"
89
+ begin
90
+ found = JSON.parse(response.body.to_s.dup.force_encoding(::Encoding::UTF_8))
91
+ if found.is_a?(Hash)
92
+ code = found["code"] if found["code"].is_a?(String)
93
+ message = found["message"] if found["message"].is_a?(String)
94
+ end
95
+ rescue JSON::ParserError, ::EncodingError
96
+ # The status alone, then.
97
+ end
98
+ raise ApiError.new(status, code, message)
99
+ end
100
+ end
101
+
102
+ # One path component, escaped.
103
+ #
104
+ # Ids come back from the server, and the server is the party this product
105
+ # distrusts: an id carrying a slash or a query string would otherwise change
106
+ # which endpoint a call reaches.
107
+ def self.path_segment(value)
108
+ value.to_s.b.each_byte.map do |byte|
109
+ char = byte.chr
110
+ char.match?(/[A-Za-z0-9\-_.~]/) ? char : format("%%%02X", byte)
111
+ end.join
112
+ end
113
+ end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mailcycle
4
+ # The account's keys, derived from its recovery phrase on this machine.
5
+ #
6
+ # Nothing here leaves the machine. Deriving is 120,000 PBKDF2 iterations for
7
+ # a 64-byte seed, which OpenSSL does in a few tens of milliseconds. The app
8
+ # computes the same bytes in its own JavaScript and takes far longer over it;
9
+ # the iteration count is the format either way, and changing it would change
10
+ # every account id ever derived.
11
+ class Keys
12
+ def self.from_phrase(phrase, passphrase = "")
13
+ problem = Crypto::Mnemonic.validate(phrase)
14
+ raise PhraseError, problem unless problem.nil?
15
+
16
+ new(Crypto::Identity.identity_from_seed(Crypto::Mnemonic.phrase_to_seed(phrase, passphrase)))
17
+ end
18
+
19
+ def initialize(identity)
20
+ @identity = identity
21
+ end
22
+
23
+ # Public, safe to log, and meaningless on its own.
24
+ def account_id
25
+ @identity.account_id
26
+ end
27
+
28
+ def auth_key
29
+ @identity.auth_key
30
+ end
31
+
32
+ def auth_public_key
33
+ @identity.auth_public_key
34
+ end
35
+
36
+ def auth_secret
37
+ @identity.auth_secret
38
+ end
39
+
40
+ def auth_verifier
41
+ Crypto::Identity.auth_verifier(@identity.auth_secret)
42
+ end
43
+
44
+ def vault_key
45
+ @identity.vault_key
46
+ end
47
+
48
+ # The address's public key, base64url, which lets the mail server seal to
49
+ # it. `epoch` is 0 unless the address's keys were rotated.
50
+ def address_public_key(address_id, epoch: 0)
51
+ Encoding.to_b64u(pair_at(address_id, epoch)[1])
52
+ end
53
+
54
+ def address_private_key(address_id, epoch: 0)
55
+ pair_at(address_id, epoch)[0]
56
+ end
57
+
58
+ # The key the account's own devices seal mail under.
59
+ def address_symmetric_key(address_id, epoch: 0)
60
+ key = Crypto::Identity.address_key_at(vault_key, address_id, epoch)
61
+ raise DecryptionError if key.nil?
62
+
63
+ key
64
+ end
65
+
66
+ def inspect
67
+ # Never the key material, however convenient that would be in a log.
68
+ "#<Mailcycle::Keys account_id=#{account_id.inspect}>"
69
+ end
70
+ alias to_s inspect
71
+
72
+ private
73
+
74
+ def pair_at(address_id, epoch)
75
+ pair = Crypto::Identity.address_key_pair_at(vault_key, address_id, epoch)
76
+ raise DecryptionError if pair.nil?
77
+
78
+ pair
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,143 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mailcycle
4
+ # Opening a stored message.
5
+ #
6
+ # Two seals reach a client. Mail from outside, and copies of what was sent,
7
+ # are sealed by the mail server to the address's public key; mail the
8
+ # account's own devices delivered is sealed under the address's symmetric
9
+ # key. Either opens here, with keys derived from the phrase.
10
+ #
11
+ # Without the keys, or with the wrong ones, a message comes back with
12
+ # `opened: false` and empty content rather than an error, so one unreadable
13
+ # message does not hide the rest of a page.
14
+ module Mail
15
+ module_function
16
+
17
+ def address_of(value)
18
+ case value
19
+ when String then value
20
+ when Hash then Wire.text(value["address"])
21
+ else ""
22
+ end
23
+ end
24
+
25
+ def name_of(value)
26
+ value.is_a?(Hash) ? Wire.text(value["name"]) : ""
27
+ end
28
+
29
+ def attachments_of(value)
30
+ Wire.array(value).each_with_index.map do |item, index|
31
+ item = Wire.object(item)
32
+ size = [item["size"], item["sizeBytes"]].find { |n| n.is_a?(Integer) && n >= 0 } || 0
33
+ filename = Wire.text(item["filename"])
34
+ content_id = Wire.text(item["contentId"])
35
+ Attachment.new(
36
+ index: index,
37
+ filename: filename.empty? ? "attachment" : filename,
38
+ mime_type: Wire.text(item["mimeType"]),
39
+ size: size,
40
+ content_id: content_id.empty? ? nil : content_id,
41
+ # Only an explicit false: mail sealed before the field existed
42
+ # always had a body.
43
+ stored: Wire.flag(item["stored"], true)
44
+ )
45
+ end
46
+ end
47
+
48
+ # A message's key epoch: absent from servers before epochs, meaning 0.
49
+ def key_epoch(wire)
50
+ epoch = wire["keyEpoch"]
51
+ epoch.is_a?(Integer) && epoch.between?(0, 0xffff_ffff) ? epoch : 0
52
+ end
53
+
54
+ # The content, opened, or nil.
55
+ def open_content(keys, wire)
56
+ return nil if keys.nil?
57
+
58
+ content = wire["content"]
59
+ return nil if content.nil?
60
+
61
+ address_id = Wire.text(wire["inboxId"])
62
+ message_id = Wire.text(wire["id"])
63
+ # The key for the epoch it was sealed under: 0 unless the address's keys
64
+ # were rotated.
65
+ epoch = key_epoch(wire)
66
+
67
+ # An asymmetric box is the one with an ephemeral public key in it.
68
+ if content.is_a?(Hash) && content["epk"].is_a?(String)
69
+ box = Crypto::AsymmetricBox.from_wire(content)
70
+ return nil if box.nil?
71
+
72
+ private_key = keys.address_private_key(address_id, epoch: epoch)
73
+ # The additional data binds the ciphertext to this address and this
74
+ # message, exactly as the mail server sealed it.
75
+ Crypto::Asymmetric.open_json_with_private_key(private_key, box, "#{address_id}:#{message_id}")
76
+ else
77
+ box = Crypto::SealedBox.from_wire(content)
78
+ return nil if box.nil?
79
+
80
+ key = keys.address_symmetric_key(address_id, epoch: epoch)
81
+ Crypto::Cipher.open_json(key, box, message_id)
82
+ end
83
+ rescue Error
84
+ nil
85
+ end
86
+
87
+ def first_text(body, *fields)
88
+ fields.each do |field|
89
+ value = Wire.text(body[field])
90
+ return value unless value.empty?
91
+ end
92
+ ""
93
+ end
94
+
95
+ def to_message(keys, wire)
96
+ wire = Wire.object(wire)
97
+ content = open_content(keys, wire)
98
+ body = Wire.object(content)
99
+
100
+ html = first_text(body, "html", "bodyHtml")
101
+ from_address = first_text(body, "fromAddress")
102
+ from_address = address_of(body["from"]) if from_address.empty?
103
+ to = body["to"].is_a?(Array) ? address_of(body["to"].first) : address_of(body["to"])
104
+ stripped = html.empty? ? nil : Trackers.strip_remote_content(html)
105
+ from_name = first_text(body, "fromName")
106
+ from_name = name_of(body["from"]) if from_name.empty?
107
+ reply_to = first_text(body, "replyTo")
108
+ folder = Wire.text(wire["folder"])
109
+
110
+ Message.new(
111
+ id: Wire.text(wire["id"]),
112
+ address_id: Wire.text(wire["inboxId"]),
113
+ received_at: Wire.text(wire["receivedAt"]),
114
+ read: Wire.flag(wire["read"], false),
115
+ starred: Wire.flag(wire["starred"], false),
116
+ folder: folder.empty? ? "inbox" : folder,
117
+ size_bytes: Wire.count(wire["sizeBytes"]),
118
+ sent_via: Wire.maybe_text(wire["sentVia"]),
119
+ key_epoch: key_epoch(wire),
120
+ opened: !content.nil?,
121
+ from_address: from_address,
122
+ from_name: from_name,
123
+ reply_to: reply_to.empty? ? from_address : reply_to,
124
+ to: to,
125
+ subject: Wire.text(body["subject"]),
126
+ text: first_text(body, "text", "bodyText"),
127
+ html: html,
128
+ safe_html: stripped ? stripped.html : "",
129
+ blocked: stripped ? stripped.blocked : [],
130
+ message_id: Wire.text(body["messageId"]),
131
+ attachments: attachments_of(body["attachments"])
132
+ )
133
+ end
134
+
135
+ # An attachment body, opened with the key for the epoch its message was
136
+ # sealed under.
137
+ def open_attachment_bytes(keys, address_id, message_id, index, data, epoch: 0)
138
+ private_key = keys.address_private_key(address_id, epoch: epoch)
139
+ Crypto::Asymmetric.open_attachment_with_key(private_key, address_id, "#{address_id}/#{message_id}/#{index}",
140
+ data)
141
+ end
142
+ end
143
+ end