mail_daemon 0.1.0 → 1.0.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 +5 -5
- data/.rspec +3 -0
- data/.ruby-version +1 -1
- data/lib/mail_daemon/email_body_parser.rb +2 -2
- data/lib/mail_daemon/helpers.rb +2 -1
- data/lib/mail_daemon/imap/connection.rb +1 -1
- data/lib/mail_daemon/imap/watcher.rb +1 -0
- data/lib/mail_daemon/imap_watcher.rb +11 -8
- data/lib/mail_daemon/version.rb +1 -1
- data/lib/mail_daemon.rb +3 -1
- data/mail_daemon.gemspec +9 -2
- data/spec/examples.txt +147 -0
- data/spec/mail_daemon/email_body_parser_spec.rb +114 -0
- data/spec/mail_daemon/email_handler_spec.rb +309 -0
- data/spec/mail_daemon/encryption_spec.rb +161 -0
- data/spec/mail_daemon/handler_spec.rb +233 -0
- data/spec/mail_daemon/helpers_spec.rb +144 -0
- data/spec/mail_daemon/imap/connection_spec.rb +306 -0
- data/spec/mail_daemon/imap/statuses_spec.rb +47 -0
- data/spec/mail_daemon/imap/watcher_spec.rb +128 -0
- data/spec/mail_daemon/imap_watcher_spec.rb +205 -0
- data/spec/mail_daemon/version_spec.rb +15 -0
- data/spec/spec_helper.rb +36 -0
- metadata +142 -9
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
require 'spec_helper'
|
|
2
|
+
|
|
3
|
+
RSpec.describe MailDaemon::EmailHandler do
|
|
4
|
+
let(:options) do
|
|
5
|
+
{
|
|
6
|
+
inbound_message: email_message,
|
|
7
|
+
debug: false,
|
|
8
|
+
mailbox: { account_code: 'test_account' }
|
|
9
|
+
}
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
let(:email_message) do
|
|
13
|
+
Mail.new do
|
|
14
|
+
from 'sender@example.com'
|
|
15
|
+
to 'recipient@example.com'
|
|
16
|
+
subject 'Test Subject'
|
|
17
|
+
body 'Test body content'
|
|
18
|
+
end.to_s
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
describe '#initialize' do
|
|
22
|
+
it 'parses the email message' do
|
|
23
|
+
handler = described_class.new(options)
|
|
24
|
+
expect(handler.instance_variable_get(:@email).from).to eq(['sender@example.com'])
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
it 'extracts text body from plain text email' do
|
|
28
|
+
handler = described_class.new(options)
|
|
29
|
+
expect(handler.body).to eq('Test body content')
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
it 'extracts HTML body from multipart email' do
|
|
33
|
+
html_message = Mail.new do
|
|
34
|
+
from 'sender@example.com'
|
|
35
|
+
to 'recipient@example.com'
|
|
36
|
+
subject 'Test Subject'
|
|
37
|
+
text_part do
|
|
38
|
+
body 'Plain text'
|
|
39
|
+
end
|
|
40
|
+
html_part do
|
|
41
|
+
content_type 'text/html; charset=UTF-8'
|
|
42
|
+
body '<p>HTML content</p>'
|
|
43
|
+
end
|
|
44
|
+
end.to_s
|
|
45
|
+
|
|
46
|
+
handler = described_class.new(options.merge(inbound_message: html_message))
|
|
47
|
+
expect(handler.body).to include('<p>HTML content</p>')
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
it 'prefers HTML body over text body' do
|
|
51
|
+
multipart_message = Mail.new do
|
|
52
|
+
from 'sender@example.com'
|
|
53
|
+
to 'recipient@example.com'
|
|
54
|
+
subject 'Test Subject'
|
|
55
|
+
text_part do
|
|
56
|
+
body 'Plain text'
|
|
57
|
+
end
|
|
58
|
+
html_part do
|
|
59
|
+
content_type 'text/html; charset=UTF-8'
|
|
60
|
+
body '<p>HTML content</p>'
|
|
61
|
+
end
|
|
62
|
+
end.to_s
|
|
63
|
+
|
|
64
|
+
handler = described_class.new(options.merge(inbound_message: multipart_message))
|
|
65
|
+
expect(handler.body).to include('<p>HTML content</p>')
|
|
66
|
+
expect(handler.body).not_to include('Plain text')
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
it 'handles encoding issues gracefully' do
|
|
70
|
+
# Create message with invalid UTF-8 bytes, but encode as binary first
|
|
71
|
+
invalid_bytes = "\x80\x81".force_encoding('ASCII-8BIT')
|
|
72
|
+
message_with_encoding = Mail.new do
|
|
73
|
+
from 'sender@example.com'
|
|
74
|
+
to 'recipient@example.com'
|
|
75
|
+
subject 'Test Subject'
|
|
76
|
+
body invalid_bytes
|
|
77
|
+
end.to_s
|
|
78
|
+
|
|
79
|
+
expect { described_class.new(options.merge(inbound_message: message_with_encoding)) }.not_to raise_error
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
describe '#generate_message_hash' do
|
|
84
|
+
it 'returns a hash with email details' do
|
|
85
|
+
handler = described_class.new(options)
|
|
86
|
+
message_hash = handler.generate_message_hash
|
|
87
|
+
|
|
88
|
+
expect(message_hash).to include(
|
|
89
|
+
'from' => ['sender@example.com'],
|
|
90
|
+
'to' => ['recipient@example.com'],
|
|
91
|
+
'subject' => 'Test Subject',
|
|
92
|
+
'body' => 'Test body content'
|
|
93
|
+
)
|
|
94
|
+
expect(message_hash).to have_key('attachements')
|
|
95
|
+
expect(message_hash).to have_key('metadata')
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
it 'does not include inbound_message in hash' do
|
|
99
|
+
handler = described_class.new(options)
|
|
100
|
+
message_hash = handler.generate_message_hash
|
|
101
|
+
expect(message_hash).not_to have_key(:inbound_message)
|
|
102
|
+
expect(message_hash).not_to have_key('inbound_message')
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
describe '#fetch_metadata' do
|
|
107
|
+
it 'returns metadata with references' do
|
|
108
|
+
message_with_refs = Mail.new do
|
|
109
|
+
from 'sender@example.com'
|
|
110
|
+
to 'recipient@example.com'
|
|
111
|
+
subject 'Test Subject'
|
|
112
|
+
body 'Test body'
|
|
113
|
+
references '<ref1@example.com> <ref2@example.com>'
|
|
114
|
+
end.to_s
|
|
115
|
+
|
|
116
|
+
handler = described_class.new(options.merge(inbound_message: message_with_refs))
|
|
117
|
+
metadata = handler.fetch_metadata
|
|
118
|
+
|
|
119
|
+
expect(metadata).to have_key('references')
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
it 'extracts case_id from references' do
|
|
123
|
+
message_with_case_ref = Mail.new do
|
|
124
|
+
from 'sender@example.com'
|
|
125
|
+
to 'recipient@example.com'
|
|
126
|
+
subject 'Test Subject'
|
|
127
|
+
body 'Test body'
|
|
128
|
+
references '<12345.case@emergeadapt.com>'
|
|
129
|
+
end.to_s
|
|
130
|
+
|
|
131
|
+
handler = described_class.new(options.merge(inbound_message: message_with_case_ref))
|
|
132
|
+
metadata = handler.fetch_metadata
|
|
133
|
+
|
|
134
|
+
expect(metadata).to have_key('case_id')
|
|
135
|
+
expect(metadata['case_id']).to eq('12345')
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
it 'extracts message_id from references' do
|
|
139
|
+
message_with_msg_ref = Mail.new do
|
|
140
|
+
from 'sender@example.com'
|
|
141
|
+
to 'recipient@example.com'
|
|
142
|
+
subject 'Test Subject'
|
|
143
|
+
body 'Test body'
|
|
144
|
+
references '<67890.message@emergeadapt.com>'
|
|
145
|
+
end.to_s
|
|
146
|
+
|
|
147
|
+
handler = described_class.new(options.merge(inbound_message: message_with_msg_ref))
|
|
148
|
+
metadata = handler.fetch_metadata
|
|
149
|
+
|
|
150
|
+
expect(metadata).to have_key('message_id')
|
|
151
|
+
expect(metadata['message_id']).to eq('67890')
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
it 'returns empty hash when no matching references' do
|
|
155
|
+
handler = described_class.new(options)
|
|
156
|
+
metadata = handler.fetch_metadata
|
|
157
|
+
|
|
158
|
+
expect(metadata).to eq({ 'references' => [] })
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
it 'handles empty references gracefully' do
|
|
162
|
+
handler = described_class.new(options)
|
|
163
|
+
# Mock references to return empty array
|
|
164
|
+
allow(handler).to receive(:references).and_return([])
|
|
165
|
+
metadata = handler.fetch_metadata
|
|
166
|
+
|
|
167
|
+
expect(metadata).to eq({ 'references' => [] })
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
describe '#references' do
|
|
172
|
+
it 'returns message IDs from References header' do
|
|
173
|
+
message_with_refs = Mail.new do
|
|
174
|
+
from 'sender@example.com'
|
|
175
|
+
to 'recipient@example.com'
|
|
176
|
+
subject 'Test Subject'
|
|
177
|
+
body 'Test body'
|
|
178
|
+
references '<ref1@example.com> <ref2@example.com>'
|
|
179
|
+
end.to_s
|
|
180
|
+
|
|
181
|
+
handler = described_class.new(options.merge(inbound_message: message_with_refs))
|
|
182
|
+
refs = handler.references
|
|
183
|
+
|
|
184
|
+
expect(refs).to be_an(Array)
|
|
185
|
+
expect(refs.length).to be > 0
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
it 'returns empty array when no References header' do
|
|
189
|
+
handler = described_class.new(options)
|
|
190
|
+
expect(handler.references).to eq([])
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
describe '#prepare_attachments' do
|
|
195
|
+
let(:temp_dir) { '/tmp/caseblocks/test_account/attachments' }
|
|
196
|
+
|
|
197
|
+
before do
|
|
198
|
+
# Create a temporary directory for testing
|
|
199
|
+
FileUtils.mkdir_p(temp_dir) unless File.exist?(temp_dir)
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
after do
|
|
203
|
+
# Clean up test files
|
|
204
|
+
FileUtils.rm_rf('/tmp/caseblocks') if File.exist?('/tmp/caseblocks')
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
it 'saves attachments to disk' do
|
|
208
|
+
message_with_attachment = Mail.new do
|
|
209
|
+
from 'sender@example.com'
|
|
210
|
+
to 'recipient@example.com'
|
|
211
|
+
subject 'Test Subject'
|
|
212
|
+
body 'Test body'
|
|
213
|
+
add_file filename: 'test.txt', content: 'attachment content'
|
|
214
|
+
end.to_s
|
|
215
|
+
|
|
216
|
+
handler = described_class.new(options.merge(inbound_message: message_with_attachment))
|
|
217
|
+
attachments = handler.prepare_attachments
|
|
218
|
+
|
|
219
|
+
expect(attachments).to be_an(Array)
|
|
220
|
+
expect(attachments.length).to eq(1)
|
|
221
|
+
expect(attachments.first).to have_key('filename')
|
|
222
|
+
expect(attachments.first).to have_key('filepath')
|
|
223
|
+
expect(attachments.first['filename']).to eq('test.txt')
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
it 'returns empty array when no attachments' do
|
|
227
|
+
handler = described_class.new(options)
|
|
228
|
+
expect(handler.prepare_attachments).to eq([])
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
it 'creates attachment directory' do
|
|
232
|
+
# Remove directory first to test creation
|
|
233
|
+
FileUtils.rm_rf(temp_dir) if File.exist?(temp_dir)
|
|
234
|
+
|
|
235
|
+
message_with_attachment = Mail.new do
|
|
236
|
+
from 'sender@example.com'
|
|
237
|
+
to 'recipient@example.com'
|
|
238
|
+
subject 'Test Subject'
|
|
239
|
+
body 'Test body'
|
|
240
|
+
add_file filename: 'test.txt', content: 'attachment content'
|
|
241
|
+
end.to_s
|
|
242
|
+
|
|
243
|
+
handler = described_class.new(options.merge(inbound_message: message_with_attachment))
|
|
244
|
+
handler.prepare_attachments
|
|
245
|
+
|
|
246
|
+
expect(File.exist?(temp_dir)).to be true
|
|
247
|
+
end
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
describe '#content_type' do
|
|
251
|
+
it 'returns text/plain for plain text emails' do
|
|
252
|
+
handler = described_class.new(options)
|
|
253
|
+
expect(handler.content_type).to eq('text/plain')
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
it 'returns text/html for HTML emails' do
|
|
257
|
+
html_message = Mail.new do
|
|
258
|
+
from 'sender@example.com'
|
|
259
|
+
to 'recipient@example.com'
|
|
260
|
+
subject 'Test Subject'
|
|
261
|
+
html_part do
|
|
262
|
+
content_type 'text/html; charset=UTF-8'
|
|
263
|
+
body '<p>HTML content</p>'
|
|
264
|
+
end
|
|
265
|
+
end.to_s
|
|
266
|
+
|
|
267
|
+
handler = described_class.new(options.merge(inbound_message: html_message))
|
|
268
|
+
expect(handler.content_type).to eq('text/html')
|
|
269
|
+
end
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
describe '#sanitize!' do
|
|
273
|
+
it 'sanitizes HTML content' do
|
|
274
|
+
html_message = Mail.new do
|
|
275
|
+
from 'sender@example.com'
|
|
276
|
+
to 'recipient@example.com'
|
|
277
|
+
subject 'Test Subject'
|
|
278
|
+
html_part do
|
|
279
|
+
content_type 'text/html; charset=UTF-8'
|
|
280
|
+
body '<p>HTML content <script>alert("xss")</script></p>'
|
|
281
|
+
end
|
|
282
|
+
end.to_s
|
|
283
|
+
|
|
284
|
+
handler = described_class.new(options.merge(inbound_message: html_message))
|
|
285
|
+
handler.sanitize!
|
|
286
|
+
|
|
287
|
+
expect(handler.body).not_to include('<script>')
|
|
288
|
+
end
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
describe '#clean_replies!' do
|
|
292
|
+
it 'calls EmailBodyParser.parse' do
|
|
293
|
+
handler = described_class.new(options)
|
|
294
|
+
original_body = handler.body
|
|
295
|
+
allow(MailDaemon::EmailBodyParser).to receive(:parse).and_return('cleaned body')
|
|
296
|
+
|
|
297
|
+
handler.clean_replies!
|
|
298
|
+
expect(MailDaemon::EmailBodyParser).to have_received(:parse).with(original_body, 'text/plain')
|
|
299
|
+
expect(handler.body).to eq('cleaned body')
|
|
300
|
+
end
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
describe '#body' do
|
|
304
|
+
it 'returns the email body' do
|
|
305
|
+
handler = described_class.new(options)
|
|
306
|
+
expect(handler.body).to eq('Test body content')
|
|
307
|
+
end
|
|
308
|
+
end
|
|
309
|
+
end
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
require 'spec_helper'
|
|
2
|
+
|
|
3
|
+
RSpec.describe MailDaemon::Encryption do
|
|
4
|
+
# Use fixed valid 16-byte keys for AES-128 (Base64 encoded) to ensure consistency
|
|
5
|
+
# These are valid 16-byte keys when Base64 decoded
|
|
6
|
+
let(:valid_key) { Base64.encode64('0123456789abcdef').strip }
|
|
7
|
+
let(:valid_iv) { Base64.encode64('fedcba9876543210').strip }
|
|
8
|
+
|
|
9
|
+
describe '#initialize' do
|
|
10
|
+
it 'sets default key from environment variable' do
|
|
11
|
+
allow(ENV).to receive(:[]).with('CASEBLOCKS_ENCRYPTION_KEY').and_return('custom_key')
|
|
12
|
+
allow(ENV).to receive(:[]).with('CASEBLOCKS_ENCRYPTION_IV').and_return(nil)
|
|
13
|
+
encryption = described_class.new
|
|
14
|
+
expect(encryption.instance_variable_get(:@key)).to eq('custom_key')
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
it 'uses default key when environment variable not set' do
|
|
18
|
+
allow(ENV).to receive(:[]).with('CASEBLOCKS_ENCRYPTION_KEY').and_return(nil)
|
|
19
|
+
allow(ENV).to receive(:[]).with('CASEBLOCKS_ENCRYPTION_IV').and_return(nil)
|
|
20
|
+
# Note: The default key in the code is 32 bytes (AES-256) but code uses AES-128 (16 bytes)
|
|
21
|
+
# This test just verifies the default is used, but it will fail at runtime
|
|
22
|
+
encryption = described_class.new
|
|
23
|
+
expect(encryption.instance_variable_get(:@key)).to eq('YLX0IBT+OXaO4mP2bVYqzMPbrrss8eUcX1XtgLxlVH8=')
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
it 'sets default IV from environment variable' do
|
|
27
|
+
allow(ENV).to receive(:[]).with('CASEBLOCKS_ENCRYPTION_KEY').and_return(nil)
|
|
28
|
+
allow(ENV).to receive(:[]).with('CASEBLOCKS_ENCRYPTION_IV').and_return('custom_iv')
|
|
29
|
+
encryption = described_class.new
|
|
30
|
+
expect(encryption.instance_variable_get(:@iv)).to eq('custom_iv')
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
it 'uses default IV when environment variable not set' do
|
|
34
|
+
allow(ENV).to receive(:[]).with('CASEBLOCKS_ENCRYPTION_KEY').and_return(nil)
|
|
35
|
+
allow(ENV).to receive(:[]).with('CASEBLOCKS_ENCRYPTION_IV').and_return(nil)
|
|
36
|
+
encryption = described_class.new
|
|
37
|
+
expect(encryption.instance_variable_get(:@iv)).to eq('vvSVfoWvZQ3T/DfjsjO/9w==')
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
describe '#encrypt' do
|
|
42
|
+
# Set up valid keys for encrypt tests (AES-128 requires 16-byte keys)
|
|
43
|
+
let(:encryption) do
|
|
44
|
+
allow(ENV).to receive(:[]).with('CASEBLOCKS_ENCRYPTION_KEY').and_return(valid_key)
|
|
45
|
+
allow(ENV).to receive(:[]).with('CASEBLOCKS_ENCRYPTION_IV').and_return(valid_iv)
|
|
46
|
+
described_class.new
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
it 'encrypts a string' do
|
|
50
|
+
plaintext = 'sensitive data'
|
|
51
|
+
encrypted = encryption.encrypt(plaintext)
|
|
52
|
+
|
|
53
|
+
expect(encrypted).not_to eq(plaintext)
|
|
54
|
+
expect(encrypted).to be_a(String)
|
|
55
|
+
expect(encrypted.length).to be > 0
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
it 'returns Base64 encoded string' do
|
|
59
|
+
plaintext = 'test data'
|
|
60
|
+
encrypted = encryption.encrypt(plaintext)
|
|
61
|
+
|
|
62
|
+
# Base64 strings don't have trailing newlines (stripped with [0..-2])
|
|
63
|
+
expect(encrypted).not_to end_with("\n")
|
|
64
|
+
expect { Base64.decode64(encrypted) }.not_to raise_error
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
it 'returns nil when data is nil' do
|
|
68
|
+
expect(encryption.encrypt(nil)).to be_nil
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
it 'produces different output for same input (due to IV)' do
|
|
72
|
+
plaintext = 'test data'
|
|
73
|
+
encrypted1 = encryption.encrypt(plaintext)
|
|
74
|
+
encrypted2 = encryption.encrypt(plaintext)
|
|
75
|
+
|
|
76
|
+
# Even with same IV, encrypted data should be consistent
|
|
77
|
+
# But with different IVs, they would differ
|
|
78
|
+
expect(encrypted1).to be_a(String)
|
|
79
|
+
expect(encrypted2).to be_a(String)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
it 'can encrypt empty string' do
|
|
83
|
+
encrypted = encryption.encrypt('')
|
|
84
|
+
expect(encrypted).to be_a(String)
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
describe '#decrypt' do
|
|
89
|
+
# Set up valid keys for decrypt tests (AES-128 requires 16-byte keys)
|
|
90
|
+
let(:encryption) do
|
|
91
|
+
allow(ENV).to receive(:[]).with('CASEBLOCKS_ENCRYPTION_KEY').and_return(valid_key)
|
|
92
|
+
allow(ENV).to receive(:[]).with('CASEBLOCKS_ENCRYPTION_IV').and_return(valid_iv)
|
|
93
|
+
described_class.new
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
it 'decrypts an encrypted string' do
|
|
97
|
+
plaintext = 'sensitive data'
|
|
98
|
+
encrypted = encryption.encrypt(plaintext)
|
|
99
|
+
decrypted = encryption.decrypt(encrypted)
|
|
100
|
+
|
|
101
|
+
expect(decrypted).to eq(plaintext)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
it 'returns nil when value is nil' do
|
|
105
|
+
expect(encryption.decrypt(nil)).to be_nil
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
it 'round-trips encryption and decryption' do
|
|
109
|
+
test_cases = [
|
|
110
|
+
'simple text',
|
|
111
|
+
'text with special chars: !@#$%^&*()',
|
|
112
|
+
'multiline\ntext\nhere',
|
|
113
|
+
'unicode: 测试 🚀',
|
|
114
|
+
''
|
|
115
|
+
]
|
|
116
|
+
|
|
117
|
+
test_cases.each do |plaintext|
|
|
118
|
+
encrypted = encryption.encrypt(plaintext)
|
|
119
|
+
decrypted = encryption.decrypt(encrypted)
|
|
120
|
+
# Decrypted data comes back as ASCII-8BIT, so force encoding for comparison
|
|
121
|
+
expect(decrypted.force_encoding(plaintext.encoding)).to eq(plaintext)
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
it 'raises error for invalid Base64' do
|
|
126
|
+
# Invalid Base64 will decode but produce invalid cipher data, causing CipherError
|
|
127
|
+
expect { encryption.decrypt('invalid_base64!') }.to raise_error(OpenSSL::Cipher::CipherError)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
it 'raises error for corrupted encrypted data' do
|
|
131
|
+
# Create valid Base64 but invalid encrypted data
|
|
132
|
+
invalid_data = Base64.encode64('not encrypted data')
|
|
133
|
+
expect { encryption.decrypt(invalid_data) }.to raise_error(OpenSSL::Cipher::CipherError)
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
describe 'encryption and decryption consistency' do
|
|
138
|
+
# Set up valid keys for consistency tests (AES-128 requires 16-byte keys)
|
|
139
|
+
let(:encryption) do
|
|
140
|
+
allow(ENV).to receive(:[]).with('CASEBLOCKS_ENCRYPTION_KEY').and_return(valid_key)
|
|
141
|
+
allow(ENV).to receive(:[]).with('CASEBLOCKS_ENCRYPTION_IV').and_return(valid_iv)
|
|
142
|
+
described_class.new
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
it 'maintains data integrity through encrypt/decrypt cycle' do
|
|
146
|
+
original_data = 'This is a test message with various characters: 123, abc, XYZ!'
|
|
147
|
+
encrypted = encryption.encrypt(original_data)
|
|
148
|
+
decrypted = encryption.decrypt(encrypted)
|
|
149
|
+
|
|
150
|
+
expect(decrypted).to eq(original_data)
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
it 'handles binary data' do
|
|
154
|
+
binary_data = "\x00\x01\x02\xFF\xFE\xFD".force_encoding('ASCII-8BIT')
|
|
155
|
+
encrypted = encryption.encrypt(binary_data)
|
|
156
|
+
decrypted = encryption.decrypt(encrypted)
|
|
157
|
+
|
|
158
|
+
expect(decrypted).to eq(binary_data)
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
require 'spec_helper'
|
|
2
|
+
|
|
3
|
+
RSpec.describe MailDaemon::Handler do
|
|
4
|
+
let(:options) do
|
|
5
|
+
{
|
|
6
|
+
connections: [],
|
|
7
|
+
debug: false
|
|
8
|
+
}
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
describe '#initialize' do
|
|
12
|
+
it 'sets up options' do
|
|
13
|
+
handler = described_class.new(options)
|
|
14
|
+
expect(handler.instance_variable_get(:@options)).to include(
|
|
15
|
+
connections: [],
|
|
16
|
+
debug: false
|
|
17
|
+
)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
it 'sets up signal traps for INT' do
|
|
21
|
+
# Signal.trap is called with a signal name and a block, not as a second argument
|
|
22
|
+
allow(Signal).to receive(:trap).and_call_original
|
|
23
|
+
expect(Signal).to receive(:trap).with("INT").and_call_original
|
|
24
|
+
expect(Signal).to receive(:trap).with("TERM").and_call_original
|
|
25
|
+
described_class.new(options)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
it 'sets up signal traps for TERM' do
|
|
29
|
+
# Signal.trap is called with a signal name and a block, not as a second argument
|
|
30
|
+
allow(Signal).to receive(:trap).and_call_original
|
|
31
|
+
expect(Signal).to receive(:trap).with("INT").and_call_original
|
|
32
|
+
expect(Signal).to receive(:trap).with("TERM").and_call_original
|
|
33
|
+
described_class.new(options)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
it 'defaults connections to empty array' do
|
|
37
|
+
handler = described_class.new({})
|
|
38
|
+
expect(handler.instance_variable_get(:@options)[:connections]).to eq([])
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
it 'defaults debug to false' do
|
|
42
|
+
handler = described_class.new({})
|
|
43
|
+
expect(handler.instance_variable_get(:@options)[:debug]).to eq(false)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
describe '#running?' do
|
|
48
|
+
context 'when watchers are running' do
|
|
49
|
+
it 'returns true' do
|
|
50
|
+
handler = described_class.new(options)
|
|
51
|
+
watcher = instance_double(MailDaemon::Imap::Watcher, running?: true)
|
|
52
|
+
handler.instance_variable_set(:@watchers, [watcher])
|
|
53
|
+
expect(handler.running?).to be true
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
context 'when no watchers are running' do
|
|
58
|
+
it 'returns false' do
|
|
59
|
+
handler = described_class.new(options)
|
|
60
|
+
watcher = instance_double(MailDaemon::Imap::Watcher, running?: false)
|
|
61
|
+
handler.instance_variable_set(:@watchers, [watcher])
|
|
62
|
+
expect(handler.running?).to be false
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
context 'when watchers is nil' do
|
|
67
|
+
it 'returns false' do
|
|
68
|
+
handler = described_class.new(options)
|
|
69
|
+
expect(handler.running?).to be false
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
describe '#stop' do
|
|
75
|
+
it 'stops all watchers' do
|
|
76
|
+
handler = described_class.new(options)
|
|
77
|
+
watcher1 = instance_double(MailDaemon::Imap::Watcher)
|
|
78
|
+
watcher2 = instance_double(MailDaemon::Imap::Watcher)
|
|
79
|
+
thread1 = instance_double(Thread)
|
|
80
|
+
thread2 = instance_double(Thread)
|
|
81
|
+
|
|
82
|
+
handler.instance_variable_set(:@watchers, [watcher1, watcher2])
|
|
83
|
+
handler.instance_variable_set(:@threads, [thread1, thread2])
|
|
84
|
+
|
|
85
|
+
expect(watcher1).to receive(:stop)
|
|
86
|
+
expect(watcher2).to receive(:stop)
|
|
87
|
+
expect(thread1).to receive(:kill)
|
|
88
|
+
expect(thread2).to receive(:kill)
|
|
89
|
+
|
|
90
|
+
handler.stop
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
describe '#restart' do
|
|
95
|
+
it 'stops if running' do
|
|
96
|
+
handler = described_class.new(options)
|
|
97
|
+
watcher = instance_double(MailDaemon::Imap::Watcher, running?: true)
|
|
98
|
+
handler.instance_variable_set(:@watchers, [watcher])
|
|
99
|
+
handler.instance_variable_set(:@client_handler_block, proc {})
|
|
100
|
+
|
|
101
|
+
expect(handler).to receive(:stop)
|
|
102
|
+
allow(handler).to receive(:start)
|
|
103
|
+
|
|
104
|
+
handler.restart
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
it 'starts in a new thread' do
|
|
108
|
+
handler = described_class.new(options)
|
|
109
|
+
watcher = instance_double(MailDaemon::Imap::Watcher, running?: false)
|
|
110
|
+
handler.instance_variable_set(:@watchers, [watcher])
|
|
111
|
+
|
|
112
|
+
expect(Thread).to receive(:new).and_yield
|
|
113
|
+
expect(handler).to receive(:start)
|
|
114
|
+
|
|
115
|
+
handler.restart
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
describe '#reload' do
|
|
120
|
+
it 'reloads options and restarts' do
|
|
121
|
+
handler = described_class.new(options)
|
|
122
|
+
new_options = { debug: true }
|
|
123
|
+
|
|
124
|
+
expect(handler).to receive(:restart)
|
|
125
|
+
handler.reload(new_options)
|
|
126
|
+
|
|
127
|
+
expect(handler.instance_variable_get(:@options)[:debug]).to be true
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
describe '#start' do
|
|
132
|
+
let(:mailbox_options) do
|
|
133
|
+
{
|
|
134
|
+
username: 'test@example.com',
|
|
135
|
+
password: 'password',
|
|
136
|
+
host: 'imap.example.com',
|
|
137
|
+
port: 993,
|
|
138
|
+
ssl: true
|
|
139
|
+
}
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
it 'creates watchers for each connection' do
|
|
143
|
+
handler = described_class.new(options.merge(connections: [mailbox_options]))
|
|
144
|
+
watcher = instance_double(MailDaemon::Imap::Watcher)
|
|
145
|
+
|
|
146
|
+
expect(MailDaemon::Imap::Watcher).to receive(:new).and_return(watcher)
|
|
147
|
+
# Mock start to return immediately to prevent thread.join from hanging
|
|
148
|
+
allow(watcher).to receive(:start) do |&block|
|
|
149
|
+
block.call({ type: 'incoming_email', message: 'test' }) if block
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# Run in a thread with timeout to prevent hanging
|
|
153
|
+
thread = Thread.new do
|
|
154
|
+
handler.start do |message, type|
|
|
155
|
+
# block executed
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# Wait a short time for the thread to complete, then kill it
|
|
160
|
+
sleep 0.1
|
|
161
|
+
thread.kill if thread.alive?
|
|
162
|
+
thread.join(0.1)
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
it 'sets up SSL options when ssl is enabled' do
|
|
166
|
+
handler = described_class.new(options.merge(connections: [mailbox_options.merge(ssl: true)]))
|
|
167
|
+
watcher = instance_double(MailDaemon::Imap::Watcher)
|
|
168
|
+
|
|
169
|
+
expect(MailDaemon::Imap::Watcher).to receive(:new) do |opts|
|
|
170
|
+
expect(opts[:ssl_options]).to eq({ verify_mode: OpenSSL::SSL::VERIFY_NONE })
|
|
171
|
+
watcher
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
allow(watcher).to receive(:start) do |&block|
|
|
175
|
+
block.call({ type: 'incoming_email', message: 'test' }) if block
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
thread = Thread.new do
|
|
179
|
+
handler.start { |m, t| }
|
|
180
|
+
end
|
|
181
|
+
sleep 0.1
|
|
182
|
+
thread.kill if thread.alive?
|
|
183
|
+
thread.join(0.1)
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
it 'yields messages with type' do
|
|
187
|
+
handler = described_class.new(options.merge(connections: [mailbox_options]))
|
|
188
|
+
watcher = instance_double(MailDaemon::Imap::Watcher)
|
|
189
|
+
message_data = { type: 'incoming_email', message: 'test' }
|
|
190
|
+
|
|
191
|
+
allow(MailDaemon::Imap::Watcher).to receive(:new).and_return(watcher)
|
|
192
|
+
allow(watcher).to receive(:start) do |&block|
|
|
193
|
+
block.call(message_data) if block
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
yielded_message = nil
|
|
197
|
+
yielded_type = nil
|
|
198
|
+
|
|
199
|
+
thread = Thread.new do
|
|
200
|
+
handler.start do |message, type|
|
|
201
|
+
yielded_message = message
|
|
202
|
+
yielded_type = type
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
sleep 0.1
|
|
207
|
+
thread.kill if thread.alive?
|
|
208
|
+
thread.join(0.1)
|
|
209
|
+
|
|
210
|
+
expect(yielded_message).to eq(message_data)
|
|
211
|
+
expect(yielded_type).to eq('incoming_email')
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
it 'handles exceptions in watcher threads' do
|
|
215
|
+
handler = described_class.new(options.merge(connections: [mailbox_options], debug: true))
|
|
216
|
+
watcher = instance_double(MailDaemon::Imap::Watcher)
|
|
217
|
+
|
|
218
|
+
allow(MailDaemon::Imap::Watcher).to receive(:new).and_return(watcher)
|
|
219
|
+
allow(watcher).to receive(:start).and_raise(StandardError.new("Connection failed"))
|
|
220
|
+
|
|
221
|
+
thread = Thread.new do
|
|
222
|
+
handler.start { |m, t| }
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
sleep 0.1
|
|
226
|
+
thread.kill if thread.alive?
|
|
227
|
+
thread.join(0.1)
|
|
228
|
+
|
|
229
|
+
# Should not raise error, exception is caught in the thread
|
|
230
|
+
expect(true).to be true
|
|
231
|
+
end
|
|
232
|
+
end
|
|
233
|
+
end
|