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,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mailcycle
|
|
4
|
+
# Checking a webhook came from Mailcycle.
|
|
5
|
+
#
|
|
6
|
+
# `Mailcycle-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256 of "t.body">`,
|
|
7
|
+
# keyed with the webhook's secret. Pass the raw body, before any parsing: a
|
|
8
|
+
# body that has been through a JSON round trip is a different string and
|
|
9
|
+
# will not verify.
|
|
10
|
+
module WebhookSignature
|
|
11
|
+
DEFAULT_TOLERANCE_SECONDS = 300
|
|
12
|
+
|
|
13
|
+
module_function
|
|
14
|
+
|
|
15
|
+
# The header Mailcycle would send for this body at this second.
|
|
16
|
+
#
|
|
17
|
+
# Here so a test suite can build a believable delivery without a live
|
|
18
|
+
# webhook, and so this implementation can be checked against the shared
|
|
19
|
+
# vectors rather than trusted.
|
|
20
|
+
def header(secret, raw_body, timestamp)
|
|
21
|
+
"t=#{timestamp},v1=#{Encoding.to_hex(sign(secret, raw_body, timestamp))}"
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# True if the signature is Mailcycle's, recent, and over this exact body.
|
|
25
|
+
#
|
|
26
|
+
# `now` defaults to the system clock; pass it to test, or from a trusted
|
|
27
|
+
# time source.
|
|
28
|
+
def verify(secret, raw_body, header, tolerance: DEFAULT_TOLERANCE_SECONDS, now: Time.now.to_i)
|
|
29
|
+
return false unless header.is_a?(String)
|
|
30
|
+
|
|
31
|
+
timestamp = nil
|
|
32
|
+
given = nil
|
|
33
|
+
header.split(",").each do |part|
|
|
34
|
+
name, value = part.split("=", 2)
|
|
35
|
+
next if value.nil?
|
|
36
|
+
|
|
37
|
+
# Trim the name as well as the value, matching the other SDKs, so a
|
|
38
|
+
# header written with spaces around the keys still verifies.
|
|
39
|
+
case name.strip
|
|
40
|
+
when "t" then timestamp = value.strip.match?(/\A[+-]?[0-9]+\z/) ? value.strip.to_i : nil
|
|
41
|
+
when "v1" then given = value.strip
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
return false if timestamp.nil? || given.nil?
|
|
46
|
+
return false if (now - timestamp).abs > tolerance
|
|
47
|
+
return false unless given.bytesize == 64
|
|
48
|
+
|
|
49
|
+
signature = Encoding.from_hex(given)
|
|
50
|
+
return false if signature.nil?
|
|
51
|
+
|
|
52
|
+
Crypto::Primitives.constant_time_equal(sign(secret, raw_body, timestamp), signature)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def sign(secret, raw_body, timestamp)
|
|
56
|
+
Crypto::Primitives.hmac_sha256(secret.to_s.b, "#{timestamp}.".b + raw_body.to_s.b)
|
|
57
|
+
end
|
|
58
|
+
private_class_method :sign
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "io/wait"
|
|
4
|
+
require "openssl"
|
|
5
|
+
require "socket"
|
|
6
|
+
require "uri"
|
|
7
|
+
|
|
8
|
+
module Mailcycle
|
|
9
|
+
# The client half of RFC 6455, as much of it as the event stream uses.
|
|
10
|
+
#
|
|
11
|
+
# Ruby's standard library has no WebSocket, and the stream is too small a
|
|
12
|
+
# part of this library to take a dependency for. The protocol's client side
|
|
13
|
+
# is short: an HTTP upgrade, then frames, masked going out and unmasked
|
|
14
|
+
# coming in. Text and close frames are what the server sends, with pings
|
|
15
|
+
# answered as they arrive. TLS is OpenSSL's, with the peer's certificate and
|
|
16
|
+
# host name checked.
|
|
17
|
+
class WebSocket
|
|
18
|
+
GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
|
|
19
|
+
# Generous for one event, which is a few hundred bytes of ids.
|
|
20
|
+
MAX_MESSAGE_BYTES = 16 * 1024 * 1024
|
|
21
|
+
|
|
22
|
+
OP_CONTINUATION = 0x0
|
|
23
|
+
OP_TEXT = 0x1
|
|
24
|
+
OP_BINARY = 0x2
|
|
25
|
+
OP_CLOSE = 0x8
|
|
26
|
+
OP_PING = 0x9
|
|
27
|
+
OP_PONG = 0xA
|
|
28
|
+
|
|
29
|
+
# The server answered the upgrade with something other than 101.
|
|
30
|
+
class Refused < StandardError
|
|
31
|
+
attr_reader :status
|
|
32
|
+
|
|
33
|
+
def initialize(status)
|
|
34
|
+
@status = status
|
|
35
|
+
super("the server answered #{status} to the upgrade")
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# The connection broke, or the server spoke something that is not the
|
|
40
|
+
# protocol.
|
|
41
|
+
class Broken < StandardError; end
|
|
42
|
+
|
|
43
|
+
# What `read` returns: a whole text or binary message, or the server's
|
|
44
|
+
# close.
|
|
45
|
+
Frame = Struct.new(:type, :data, :code, :reason, keyword_init: true)
|
|
46
|
+
|
|
47
|
+
# Connects and upgrades. Raises Refused for an answer other than 101, and
|
|
48
|
+
# Broken or a socket error for anything else.
|
|
49
|
+
def self.connect(url, protocols: [], headers: {}, timeout: 15)
|
|
50
|
+
uri = URI.parse(url)
|
|
51
|
+
secure = uri.scheme == "wss"
|
|
52
|
+
raise Broken, "not a WebSocket address: #{url}" unless %w[ws wss].include?(uri.scheme) && uri.host
|
|
53
|
+
|
|
54
|
+
socket = Socket.tcp(uri.host, uri.port || (secure ? 443 : 80), connect_timeout: timeout)
|
|
55
|
+
socket = tls(socket, uri.host, timeout) if secure
|
|
56
|
+
client = new(socket)
|
|
57
|
+
client.__send__(:handshake, uri, protocols, headers, timeout)
|
|
58
|
+
client
|
|
59
|
+
rescue StandardError
|
|
60
|
+
socket&.close
|
|
61
|
+
raise
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# TLS over a connected socket, with the certificate and host name checked.
|
|
65
|
+
def self.tls(socket, host, timeout)
|
|
66
|
+
context = OpenSSL::SSL::SSLContext.new
|
|
67
|
+
context.set_params(verify_mode: OpenSSL::SSL::VERIFY_PEER)
|
|
68
|
+
ssl = OpenSSL::SSL::SSLSocket.new(socket, context)
|
|
69
|
+
ssl.hostname = host
|
|
70
|
+
ssl.sync_close = true
|
|
71
|
+
loop do
|
|
72
|
+
case ssl.connect_nonblock(exception: false)
|
|
73
|
+
when :wait_readable
|
|
74
|
+
raise Broken, "the TLS handshake timed out" unless socket.wait_readable(timeout)
|
|
75
|
+
when :wait_writable
|
|
76
|
+
raise Broken, "the TLS handshake timed out" unless socket.wait_writable(timeout)
|
|
77
|
+
else
|
|
78
|
+
break
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
ssl.post_connection_check(host)
|
|
82
|
+
ssl
|
|
83
|
+
rescue StandardError
|
|
84
|
+
socket.close
|
|
85
|
+
raise
|
|
86
|
+
end
|
|
87
|
+
private_class_method :tls
|
|
88
|
+
|
|
89
|
+
def initialize(socket)
|
|
90
|
+
@socket = socket
|
|
91
|
+
@buffer = +"".b
|
|
92
|
+
@write_lock = Mutex.new
|
|
93
|
+
@fragments = nil
|
|
94
|
+
@closed = false
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# The subprotocol the server chose.
|
|
98
|
+
attr_reader :protocol
|
|
99
|
+
|
|
100
|
+
# The next message, or nil when `timeout` seconds pass without one. Pings
|
|
101
|
+
# are answered on the way.
|
|
102
|
+
def read(timeout: nil)
|
|
103
|
+
deadline = timeout && (monotonic + timeout)
|
|
104
|
+
loop do
|
|
105
|
+
frame = take_frame
|
|
106
|
+
if frame.nil?
|
|
107
|
+
remaining = deadline && (deadline - monotonic)
|
|
108
|
+
return nil if remaining && remaining <= 0
|
|
109
|
+
|
|
110
|
+
fill(remaining)
|
|
111
|
+
next
|
|
112
|
+
end
|
|
113
|
+
message = handle(*frame)
|
|
114
|
+
return message if message
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def send_text(text)
|
|
119
|
+
write_frame(OP_TEXT, text.to_s.b)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Sends a close frame, best effort, and closes the socket.
|
|
123
|
+
def close(code = 1000, reason = "")
|
|
124
|
+
return if @closed
|
|
125
|
+
|
|
126
|
+
begin
|
|
127
|
+
write_frame(OP_CLOSE, [code].pack("n") + reason.to_s.b)
|
|
128
|
+
rescue StandardError
|
|
129
|
+
# Already gone: closing is all that is left.
|
|
130
|
+
end
|
|
131
|
+
close_socket
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def close_socket
|
|
135
|
+
@closed = true
|
|
136
|
+
@socket.close
|
|
137
|
+
rescue StandardError
|
|
138
|
+
nil
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def closed?
|
|
142
|
+
@closed
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
private
|
|
146
|
+
|
|
147
|
+
def monotonic
|
|
148
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def handshake(uri, protocols, headers, timeout)
|
|
152
|
+
key = [Crypto::Primitives.random_bytes(16)].pack("m0")
|
|
153
|
+
path = uri.request_uri
|
|
154
|
+
host = uri.port == uri.default_port ? uri.host : "#{uri.host}:#{uri.port}"
|
|
155
|
+
lines = [
|
|
156
|
+
"GET #{path} HTTP/1.1",
|
|
157
|
+
"Host: #{host}",
|
|
158
|
+
"Upgrade: websocket",
|
|
159
|
+
"Connection: Upgrade",
|
|
160
|
+
"Sec-WebSocket-Key: #{key}",
|
|
161
|
+
"Sec-WebSocket-Version: 13"
|
|
162
|
+
]
|
|
163
|
+
lines << "Sec-WebSocket-Protocol: #{protocols.join(', ')}" unless protocols.empty?
|
|
164
|
+
headers.each { |name, value| lines << "#{name}: #{value}" }
|
|
165
|
+
@write_lock.synchronize { @socket.write("#{lines.join("\r\n")}\r\n\r\n") }
|
|
166
|
+
|
|
167
|
+
status, answer = read_response_head(timeout)
|
|
168
|
+
raise Refused, status unless status == 101
|
|
169
|
+
|
|
170
|
+
expected = [OpenSSL::Digest::SHA1.digest(key + GUID)].pack("m0")
|
|
171
|
+
raise Broken, "the server's handshake does not match" unless answer["sec-websocket-accept"] == expected
|
|
172
|
+
|
|
173
|
+
@protocol = answer["sec-websocket-protocol"]
|
|
174
|
+
return unless !protocols.empty? && !@protocol.nil? && !protocols.include?(@protocol)
|
|
175
|
+
|
|
176
|
+
raise Broken, "the server chose a subprotocol that was not offered"
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def read_response_head(timeout)
|
|
180
|
+
deadline = monotonic + timeout
|
|
181
|
+
until (ends = @buffer.index("\r\n\r\n"))
|
|
182
|
+
raise Broken, "the upgrade answer is too long" if @buffer.bytesize > 64 * 1024
|
|
183
|
+
|
|
184
|
+
remaining = deadline - monotonic
|
|
185
|
+
raise Broken, "the upgrade timed out" if remaining <= 0
|
|
186
|
+
|
|
187
|
+
fill(remaining)
|
|
188
|
+
end
|
|
189
|
+
head = @buffer.byteslice(0, ends).force_encoding(::Encoding::BINARY)
|
|
190
|
+
@buffer = @buffer.byteslice(ends + 4, @buffer.bytesize - ends - 4)
|
|
191
|
+
status_line, *header_lines = head.split("\r\n")
|
|
192
|
+
status = status_line.to_s[%r{\AHTTP/1\.[01] ([0-9]{3})}, 1].to_i
|
|
193
|
+
answer = {}
|
|
194
|
+
header_lines.each do |line|
|
|
195
|
+
name, value = line.split(":", 2)
|
|
196
|
+
answer[name.strip.downcase] = value.to_s.strip if value
|
|
197
|
+
end
|
|
198
|
+
[status, answer]
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# Reads whatever is there into the buffer, waiting up to `timeout`
|
|
202
|
+
# seconds (forever when nil). Raises Broken at the end of the stream.
|
|
203
|
+
def fill(timeout)
|
|
204
|
+
chunk = @socket.read_nonblock(64 * 1024, exception: false)
|
|
205
|
+
case chunk
|
|
206
|
+
when :wait_readable
|
|
207
|
+
@socket.to_io.wait_readable(timeout)
|
|
208
|
+
when :wait_writable
|
|
209
|
+
@socket.to_io.wait_writable(timeout)
|
|
210
|
+
when nil
|
|
211
|
+
raise Broken, "the connection closed"
|
|
212
|
+
else
|
|
213
|
+
@buffer << chunk
|
|
214
|
+
end
|
|
215
|
+
rescue IOError, SystemCallError, OpenSSL::SSL::SSLError => e
|
|
216
|
+
raise Broken, e.message
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
# One whole frame off the front of the buffer, or nil if it has not all
|
|
220
|
+
# arrived: `[fin, opcode, payload]`.
|
|
221
|
+
def take_frame
|
|
222
|
+
return nil if @buffer.bytesize < 2
|
|
223
|
+
|
|
224
|
+
first = @buffer.getbyte(0)
|
|
225
|
+
second = @buffer.getbyte(1)
|
|
226
|
+
raise Broken, "the server masked a frame" if second & 0x80 != 0
|
|
227
|
+
raise Broken, "a frame used reserved bits" if first & 0x70 != 0
|
|
228
|
+
|
|
229
|
+
length = second & 0x7f
|
|
230
|
+
offset = 2
|
|
231
|
+
if length == 126
|
|
232
|
+
return nil if @buffer.bytesize < 4
|
|
233
|
+
|
|
234
|
+
length = @buffer.byteslice(2, 2).unpack1("n")
|
|
235
|
+
offset = 4
|
|
236
|
+
elsif length == 127
|
|
237
|
+
return nil if @buffer.bytesize < 10
|
|
238
|
+
|
|
239
|
+
length = @buffer.byteslice(2, 8).unpack1("Q>")
|
|
240
|
+
offset = 10
|
|
241
|
+
end
|
|
242
|
+
raise Broken, "a frame is too large" if length > MAX_MESSAGE_BYTES
|
|
243
|
+
return nil if @buffer.bytesize < offset + length
|
|
244
|
+
|
|
245
|
+
payload = @buffer.byteslice(offset, length)
|
|
246
|
+
@buffer = @buffer.byteslice(offset + length, @buffer.bytesize - offset - length)
|
|
247
|
+
[first & 0x80 != 0, first & 0x0f, payload]
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
# A Frame for a whole message or a close, nil for anything handled here.
|
|
251
|
+
def handle(fin, opcode, payload)
|
|
252
|
+
case opcode
|
|
253
|
+
when OP_PING
|
|
254
|
+
write_frame(OP_PONG, payload)
|
|
255
|
+
nil
|
|
256
|
+
when OP_PONG
|
|
257
|
+
nil
|
|
258
|
+
when OP_CLOSE
|
|
259
|
+
code = payload.bytesize >= 2 ? payload.unpack1("n") : 1005
|
|
260
|
+
reason = if payload.bytesize > 2
|
|
261
|
+
payload.byteslice(2,
|
|
262
|
+
payload.bytesize - 2).force_encoding(::Encoding::UTF_8)
|
|
263
|
+
else
|
|
264
|
+
""
|
|
265
|
+
end
|
|
266
|
+
Frame.new(type: :close, code: code, reason: reason.valid_encoding? ? reason : reason.scrub)
|
|
267
|
+
when OP_TEXT, OP_BINARY
|
|
268
|
+
raise Broken, "a new message began inside another" if @fragments
|
|
269
|
+
|
|
270
|
+
return message(opcode, payload) if fin
|
|
271
|
+
|
|
272
|
+
@fragments = [opcode, payload.dup]
|
|
273
|
+
nil
|
|
274
|
+
when OP_CONTINUATION
|
|
275
|
+
raise Broken, "a continuation with nothing to continue" unless @fragments
|
|
276
|
+
|
|
277
|
+
@fragments[1] << payload
|
|
278
|
+
raise Broken, "a message is too large" if @fragments[1].bytesize > MAX_MESSAGE_BYTES
|
|
279
|
+
return nil unless fin
|
|
280
|
+
|
|
281
|
+
opcode, data = @fragments
|
|
282
|
+
@fragments = nil
|
|
283
|
+
message(opcode, data)
|
|
284
|
+
else
|
|
285
|
+
raise Broken, "an unknown frame type #{opcode}"
|
|
286
|
+
end
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
def message(opcode, data)
|
|
290
|
+
return Frame.new(type: :binary, data: data) if opcode == OP_BINARY
|
|
291
|
+
|
|
292
|
+
text = data.force_encoding(::Encoding::UTF_8)
|
|
293
|
+
raise Broken, "a text frame that is not UTF-8" unless text.valid_encoding?
|
|
294
|
+
|
|
295
|
+
Frame.new(type: :text, data: text)
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
def write_frame(opcode, payload)
|
|
299
|
+
payload = payload.b
|
|
300
|
+
mask = Crypto::Primitives.random_bytes(4)
|
|
301
|
+
head = [0x80 | opcode].pack("C")
|
|
302
|
+
length = payload.bytesize
|
|
303
|
+
head << if length < 126
|
|
304
|
+
[0x80 | length].pack("C")
|
|
305
|
+
elsif length < 65_536
|
|
306
|
+
[0x80 | 126, length].pack("Cn")
|
|
307
|
+
else
|
|
308
|
+
[0x80 | 127, length].pack("CQ>")
|
|
309
|
+
end
|
|
310
|
+
@write_lock.synchronize { @socket.write(head + mask + masked(payload, mask)) }
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
def masked(payload, mask)
|
|
314
|
+
keys = mask.bytes
|
|
315
|
+
out = payload.bytes
|
|
316
|
+
out.each_index { |i| out[i] ^= keys[i & 3] }
|
|
317
|
+
out.pack("C*")
|
|
318
|
+
end
|
|
319
|
+
end
|
|
320
|
+
end
|
data/lib/mailcycle.rb
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Mailcycle for Ruby.
|
|
4
|
+
#
|
|
5
|
+
# Privacy-first email you can automate. Create addresses that receive mail,
|
|
6
|
+
# read and decrypt it on your own machine, send, and listen for events.
|
|
7
|
+
#
|
|
8
|
+
# require "mailcycle"
|
|
9
|
+
#
|
|
10
|
+
# mc = Mailcycle.sign_in(ENV.fetch("MAILCYCLE_PHRASE"))
|
|
11
|
+
# address = mc.addresses.create(label: "Sign-in codes")
|
|
12
|
+
# message = mc.messages.wait_for(address_id: address.id)
|
|
13
|
+
# puts "#{message.subject}: #{message.text}"
|
|
14
|
+
#
|
|
15
|
+
# The recovery phrase never leaves this machine. Mail is sealed to a key
|
|
16
|
+
# derived from it, so the API serves ciphertext it cannot read, and this
|
|
17
|
+
# library opens it locally. Lose the phrase and the mail is gone; nobody at
|
|
18
|
+
# Mailcycle can help.
|
|
19
|
+
#
|
|
20
|
+
# Documentation: https://docs.mailcycle.email/
|
|
21
|
+
|
|
22
|
+
require_relative "mailcycle/version"
|
|
23
|
+
require_relative "mailcycle/errors"
|
|
24
|
+
require_relative "mailcycle/encoding"
|
|
25
|
+
require_relative "mailcycle/crypto/primitives"
|
|
26
|
+
require_relative "mailcycle/crypto/wordlist"
|
|
27
|
+
require_relative "mailcycle/crypto/mnemonic"
|
|
28
|
+
require_relative "mailcycle/crypto/identity"
|
|
29
|
+
require_relative "mailcycle/crypto/cipher"
|
|
30
|
+
require_relative "mailcycle/crypto/sealed_box"
|
|
31
|
+
require_relative "mailcycle/crypto/asymmetric"
|
|
32
|
+
require_relative "mailcycle/crypto/auth_proof"
|
|
33
|
+
require_relative "mailcycle/keys"
|
|
34
|
+
require_relative "mailcycle/http"
|
|
35
|
+
require_relative "mailcycle/models"
|
|
36
|
+
require_relative "mailcycle/trackers"
|
|
37
|
+
require_relative "mailcycle/mail"
|
|
38
|
+
require_relative "mailcycle/webhook_signature"
|
|
39
|
+
require_relative "mailcycle/websocket"
|
|
40
|
+
require_relative "mailcycle/events"
|
|
41
|
+
require_relative "mailcycle/client"
|
|
42
|
+
require_relative "mailcycle/manage"
|
|
43
|
+
require_relative "mailcycle/pairing"
|
|
44
|
+
|
|
45
|
+
# The library's namespace, and shortcuts to what most programs need.
|
|
46
|
+
module Mailcycle
|
|
47
|
+
module_function
|
|
48
|
+
|
|
49
|
+
# Signs in with the recovery phrase. See Client.sign_in.
|
|
50
|
+
def sign_in(phrase, **options)
|
|
51
|
+
Client.sign_in(phrase, **options)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Picks up a session from an earlier sign-in. See Client.resume.
|
|
55
|
+
def resume(session_token, phrase, **options)
|
|
56
|
+
Client.resume(session_token, phrase, **options)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Uses an API key, with or without the phrase. See Client.with_api_key.
|
|
60
|
+
def with_api_key(api_key, phrase: nil, **options)
|
|
61
|
+
Client.with_api_key(api_key, phrase: phrase, **options)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Creates an account and signs in. Returns `[client, phrase]`. See
|
|
65
|
+
# Client.create_account.
|
|
66
|
+
def create_account(phrase = nil, **options)
|
|
67
|
+
Client.create_account(phrase, **options)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# A new twelve-word recovery phrase, generated on this machine.
|
|
71
|
+
def generate_phrase
|
|
72
|
+
Client.generate_phrase
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# nil for a good phrase, otherwise a PhraseProblem saying what is wrong.
|
|
76
|
+
def validate_phrase(phrase)
|
|
77
|
+
Crypto::Mnemonic.validate(phrase)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# True if a webhook delivery is Mailcycle's, recent, and over this exact
|
|
81
|
+
# body. Pass the body exactly as it arrived, before any parsing, and the
|
|
82
|
+
# `Mailcycle-Signature` header.
|
|
83
|
+
def verify_webhook_signature(secret, raw_body, header, tolerance: WebhookSignature::DEFAULT_TOLERANCE_SECONDS,
|
|
84
|
+
now: Time.now.to_i)
|
|
85
|
+
WebhookSignature.verify(secret, raw_body, header, tolerance: tolerance, now: now)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# The `Mailcycle-Signature` header Mailcycle would send for this body at
|
|
89
|
+
# this second, for building a believable delivery in a test.
|
|
90
|
+
def webhook_signature_header(secret, raw_body, timestamp)
|
|
91
|
+
WebhookSignature.header(secret, raw_body, timestamp)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Reads a pairing QR code's text or a typed code. See
|
|
95
|
+
# Pairing.parse_scanned_value.
|
|
96
|
+
def parse_scanned_value(raw)
|
|
97
|
+
Pairing.parse_scanned_value(raw)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# The HTML with everything remote removed, and what was. See Trackers.
|
|
101
|
+
def strip_remote_content(html)
|
|
102
|
+
Trackers.strip_remote_content(html)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# A one-line summary of what was blocked, as the app shows above a message.
|
|
106
|
+
def summarize_blocked(blocked)
|
|
107
|
+
Trackers.summarize_blocked(blocked)
|
|
108
|
+
end
|
|
109
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: mailcycle
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Northlab Studios Ltd
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: openssl
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - ">="
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '3.0'
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - ">="
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '3.0'
|
|
26
|
+
description: Create addresses that receive mail, read and decrypt it on your machine,
|
|
27
|
+
send, and listen for events.
|
|
28
|
+
email:
|
|
29
|
+
- hello@mailcycle.email
|
|
30
|
+
executables: []
|
|
31
|
+
extensions: []
|
|
32
|
+
extra_rdoc_files: []
|
|
33
|
+
files:
|
|
34
|
+
- LICENSE
|
|
35
|
+
- README.md
|
|
36
|
+
- lib/mailcycle.rb
|
|
37
|
+
- lib/mailcycle/client.rb
|
|
38
|
+
- lib/mailcycle/crypto/asymmetric.rb
|
|
39
|
+
- lib/mailcycle/crypto/auth_proof.rb
|
|
40
|
+
- lib/mailcycle/crypto/cipher.rb
|
|
41
|
+
- lib/mailcycle/crypto/identity.rb
|
|
42
|
+
- lib/mailcycle/crypto/mnemonic.rb
|
|
43
|
+
- lib/mailcycle/crypto/primitives.rb
|
|
44
|
+
- lib/mailcycle/crypto/sealed_box.rb
|
|
45
|
+
- lib/mailcycle/crypto/wordlist.rb
|
|
46
|
+
- lib/mailcycle/encoding.rb
|
|
47
|
+
- lib/mailcycle/errors.rb
|
|
48
|
+
- lib/mailcycle/events.rb
|
|
49
|
+
- lib/mailcycle/http.rb
|
|
50
|
+
- lib/mailcycle/keys.rb
|
|
51
|
+
- lib/mailcycle/mail.rb
|
|
52
|
+
- lib/mailcycle/manage.rb
|
|
53
|
+
- lib/mailcycle/models.rb
|
|
54
|
+
- lib/mailcycle/pairing.rb
|
|
55
|
+
- lib/mailcycle/trackers.rb
|
|
56
|
+
- lib/mailcycle/version.rb
|
|
57
|
+
- lib/mailcycle/webhook_signature.rb
|
|
58
|
+
- lib/mailcycle/websocket.rb
|
|
59
|
+
homepage: https://mailcycle.email/
|
|
60
|
+
licenses:
|
|
61
|
+
- Apache-2.0
|
|
62
|
+
metadata:
|
|
63
|
+
homepage_uri: https://mailcycle.email/
|
|
64
|
+
documentation_uri: https://docs.mailcycle.email/
|
|
65
|
+
rubygems_mfa_required: 'true'
|
|
66
|
+
rdoc_options: []
|
|
67
|
+
require_paths:
|
|
68
|
+
- lib
|
|
69
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
70
|
+
requirements:
|
|
71
|
+
- - ">="
|
|
72
|
+
- !ruby/object:Gem::Version
|
|
73
|
+
version: '3.1'
|
|
74
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
75
|
+
requirements:
|
|
76
|
+
- - ">="
|
|
77
|
+
- !ruby/object:Gem::Version
|
|
78
|
+
version: '0'
|
|
79
|
+
requirements: []
|
|
80
|
+
rubygems_version: 4.0.20
|
|
81
|
+
specification_version: 4
|
|
82
|
+
summary: Ruby client for the Mailcycle API.
|
|
83
|
+
test_files: []
|