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