mailcatcher-ng 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 +7 -0
- data/LICENSE +20 -0
- data/README.md +235 -0
- data/bin/catchmail +72 -0
- data/bin/mailcatcher +6 -0
- data/lib/mail_catcher/bus.rb +7 -0
- data/lib/mail_catcher/mail.rb +419 -0
- data/lib/mail_catcher/smtp.rb +68 -0
- data/lib/mail_catcher/version.rb +5 -0
- data/lib/mail_catcher/web/application.rb +247 -0
- data/lib/mail_catcher/web.rb +29 -0
- data/lib/mail_catcher.rb +257 -0
- data/lib/mailcatcher.rb +5 -0
- data/public/assets/mailcatcher.js +4 -0
- data/public/favicon.ico +0 -0
- data/views/404.erb +117 -0
- data/views/index.erb +1706 -0
- data/views/server_info.erb +236 -0
- data/views/websocket_test.erb +263 -0
- metadata +382 -0
|
@@ -0,0 +1,419 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "eventmachine"
|
|
4
|
+
require "json"
|
|
5
|
+
require "mail"
|
|
6
|
+
require "sqlite3"
|
|
7
|
+
|
|
8
|
+
module MailCatcher::Mail extend self
|
|
9
|
+
def db
|
|
10
|
+
@__db ||= begin
|
|
11
|
+
SQLite3::Database.new(":memory:", :type_translation => true).tap do |db|
|
|
12
|
+
db.execute(<<-SQL)
|
|
13
|
+
CREATE TABLE message (
|
|
14
|
+
id INTEGER PRIMARY KEY ASC,
|
|
15
|
+
sender TEXT,
|
|
16
|
+
recipients TEXT,
|
|
17
|
+
subject TEXT,
|
|
18
|
+
source BLOB,
|
|
19
|
+
size TEXT,
|
|
20
|
+
type TEXT,
|
|
21
|
+
created_at DATETIME DEFAULT CURRENT_DATETIME
|
|
22
|
+
)
|
|
23
|
+
SQL
|
|
24
|
+
db.execute(<<-SQL)
|
|
25
|
+
CREATE TABLE message_part (
|
|
26
|
+
id INTEGER PRIMARY KEY ASC,
|
|
27
|
+
message_id INTEGER NOT NULL,
|
|
28
|
+
cid TEXT,
|
|
29
|
+
type TEXT,
|
|
30
|
+
is_attachment INTEGER,
|
|
31
|
+
filename TEXT,
|
|
32
|
+
charset TEXT,
|
|
33
|
+
body BLOB,
|
|
34
|
+
size INTEGER,
|
|
35
|
+
created_at DATETIME DEFAULT CURRENT_DATETIME,
|
|
36
|
+
FOREIGN KEY (message_id) REFERENCES message (id) ON DELETE CASCADE
|
|
37
|
+
)
|
|
38
|
+
SQL
|
|
39
|
+
db.execute("PRAGMA foreign_keys = ON")
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def add_message(message)
|
|
45
|
+
@add_message_query ||= db.prepare("INSERT INTO message (sender, recipients, subject, source, type, size, created_at) VALUES (?, ?, ?, ?, ?, ?, datetime('now'))")
|
|
46
|
+
|
|
47
|
+
mail = Mail.new(message[:source])
|
|
48
|
+
@add_message_query.execute(message[:sender], JSON.generate(message[:recipients]), mail.subject, message[:source], mail.mime_type || "text/plain", message[:source].length)
|
|
49
|
+
message_id = db.last_insert_row_id
|
|
50
|
+
parts = mail.all_parts
|
|
51
|
+
parts = [mail] if parts.empty?
|
|
52
|
+
parts.each do |part|
|
|
53
|
+
body = part.body.to_s
|
|
54
|
+
# Only parts have CIDs, not mail
|
|
55
|
+
cid = part.cid if part.respond_to? :cid
|
|
56
|
+
add_message_part(message_id, cid, part.mime_type || "text/plain", part.attachment? ? 1 : 0, part.filename, part.charset, body, body.length)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
EventMachine.next_tick do
|
|
60
|
+
message = MailCatcher::Mail.message message_id
|
|
61
|
+
MailCatcher::Bus.push(type: "add", message: message)
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def add_message_part(*args)
|
|
66
|
+
@add_message_part_query ||= db.prepare "INSERT INTO message_part (message_id, cid, type, is_attachment, filename, charset, body, size, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))"
|
|
67
|
+
@add_message_part_query.execute(*args)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def latest_created_at
|
|
71
|
+
@latest_created_at_query ||= db.prepare "SELECT created_at FROM message ORDER BY created_at DESC LIMIT 1"
|
|
72
|
+
@latest_created_at_query.execute.next
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def messages
|
|
76
|
+
@messages_query ||= db.prepare "SELECT id, sender, recipients, subject, size, created_at FROM message ORDER BY created_at, id ASC"
|
|
77
|
+
@messages_query.execute.map do |row|
|
|
78
|
+
Hash[@messages_query.columns.zip(row)].tap do |message|
|
|
79
|
+
message["recipients"] &&= JSON.parse(message["recipients"])
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def message(id)
|
|
85
|
+
@message_query ||= db.prepare "SELECT id, sender, recipients, subject, size, type, created_at FROM message WHERE id = ? LIMIT 1"
|
|
86
|
+
row = @message_query.execute(id).next
|
|
87
|
+
row && Hash[@message_query.columns.zip(row)].tap do |message|
|
|
88
|
+
message["recipients"] &&= JSON.parse(message["recipients"])
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def message_source(id)
|
|
93
|
+
@message_source_query ||= db.prepare "SELECT source FROM message WHERE id = ? LIMIT 1"
|
|
94
|
+
row = @message_source_query.execute(id).next
|
|
95
|
+
row && row.first
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def message_bimi_location(id)
|
|
99
|
+
source = message_source(id)
|
|
100
|
+
return nil unless source
|
|
101
|
+
|
|
102
|
+
# Extract BIMI-Location header from email source
|
|
103
|
+
# Headers are case-insensitive
|
|
104
|
+
source.each_line do |line|
|
|
105
|
+
# Stop at first blank line (end of headers)
|
|
106
|
+
break if line.strip.empty?
|
|
107
|
+
# Match BIMI-Location header (case-insensitive)
|
|
108
|
+
if line.match?(/^bimi-location:\s*/i)
|
|
109
|
+
# Extract the value and clean it up
|
|
110
|
+
value = line.sub(/^bimi-location:\s*/i, '').strip
|
|
111
|
+
return value unless value.empty?
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
nil
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def message_preview_text(id)
|
|
119
|
+
source = message_source(id)
|
|
120
|
+
return nil unless source
|
|
121
|
+
|
|
122
|
+
# Extract Preview-Text header from email source (tier 1 of preview text extraction)
|
|
123
|
+
# This is a de facto standard header (not formal RFC) used by email clients
|
|
124
|
+
# to display preview/preheader text in the inbox preview pane
|
|
125
|
+
source.each_line do |line|
|
|
126
|
+
# Stop at first blank line (end of headers)
|
|
127
|
+
break if line.strip.empty?
|
|
128
|
+
# Match Preview-Text header (case-insensitive)
|
|
129
|
+
if line.match?(/^preview-text:\s*/i)
|
|
130
|
+
# Extract the value and clean it up
|
|
131
|
+
value = line.sub(/^preview-text:\s*/i, '').strip
|
|
132
|
+
return value unless value.empty?
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
nil
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def message_from(id)
|
|
140
|
+
source = message_source(id)
|
|
141
|
+
return nil unless source
|
|
142
|
+
|
|
143
|
+
# Extract From header from email source
|
|
144
|
+
source.each_line do |line|
|
|
145
|
+
# Stop at first blank line (end of headers)
|
|
146
|
+
break if line.strip.empty?
|
|
147
|
+
# Match From header (case-insensitive)
|
|
148
|
+
if line.match?(/^from:\s*/i)
|
|
149
|
+
# Extract the value and handle multi-line headers
|
|
150
|
+
value = line.sub(/^from:\s*/i, '').strip
|
|
151
|
+
|
|
152
|
+
# Continue reading continuation lines (lines starting with whitespace)
|
|
153
|
+
lines = source.lines
|
|
154
|
+
line_index = lines.index { |l| l.match?(/^from:\s*/i) }
|
|
155
|
+
next_index = line_index + 1 if line_index
|
|
156
|
+
while next_index && next_index < lines.length && lines[next_index].match?(/^\s+/)
|
|
157
|
+
value += " " + lines[next_index].strip
|
|
158
|
+
next_index += 1
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
return value unless value.empty?
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
nil
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def message_to(id)
|
|
169
|
+
source = message_source(id)
|
|
170
|
+
return nil unless source
|
|
171
|
+
|
|
172
|
+
# Extract To header from email source
|
|
173
|
+
source.each_line do |line|
|
|
174
|
+
# Stop at first blank line (end of headers)
|
|
175
|
+
break if line.strip.empty?
|
|
176
|
+
# Match To header (case-insensitive)
|
|
177
|
+
if line.match?(/^to:\s*/i)
|
|
178
|
+
# Extract the value and handle multi-line headers
|
|
179
|
+
value = line.sub(/^to:\s*/i, '').strip
|
|
180
|
+
|
|
181
|
+
# Continue reading continuation lines (lines starting with whitespace)
|
|
182
|
+
lines = source.lines
|
|
183
|
+
line_index = lines.index { |l| l.match?(/^to:\s*/i) }
|
|
184
|
+
next_index = line_index + 1 if line_index
|
|
185
|
+
while next_index && next_index < lines.length && lines[next_index].match?(/^\s+/)
|
|
186
|
+
value += " " + lines[next_index].strip
|
|
187
|
+
next_index += 1
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
return value unless value.empty?
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
nil
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def message_authentication_results(id)
|
|
198
|
+
source = message_source(id)
|
|
199
|
+
return {} unless source
|
|
200
|
+
|
|
201
|
+
auth_results = {
|
|
202
|
+
dmarc: nil,
|
|
203
|
+
dkim: nil,
|
|
204
|
+
spf: nil
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
lines = source.lines
|
|
208
|
+
lines.each_with_index do |line, index|
|
|
209
|
+
break if line.strip.empty?
|
|
210
|
+
|
|
211
|
+
# Authentication-Results header contains DMARC, DKIM, and SPF info
|
|
212
|
+
if line.match?(/^authentication-results:\s*/i)
|
|
213
|
+
# Extract the value and handle multi-line headers
|
|
214
|
+
value = line.sub(/^authentication-results:\s*/i, '').strip
|
|
215
|
+
|
|
216
|
+
# Continue reading continuation lines (lines starting with whitespace)
|
|
217
|
+
next_index = index + 1
|
|
218
|
+
while next_index < lines.length && lines[next_index].match?(/^\s+/)
|
|
219
|
+
value += " " + lines[next_index].strip
|
|
220
|
+
next_index += 1
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
auth_results = parse_authentication_results(value)
|
|
224
|
+
break
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
auth_results
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def message_has_html?(id)
|
|
232
|
+
@message_has_html_query ||= db.prepare "SELECT 1 FROM message_part WHERE message_id = ? AND is_attachment = 0 AND type IN ('application/xhtml+xml', 'text/html') LIMIT 1"
|
|
233
|
+
(!!@message_has_html_query.execute(id).next) || ["text/html", "application/xhtml+xml"].include?(message(id)["type"])
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def message_has_plain?(id)
|
|
237
|
+
@message_has_plain_query ||= db.prepare "SELECT 1 FROM message_part WHERE message_id = ? AND is_attachment = 0 AND type = 'text/plain' LIMIT 1"
|
|
238
|
+
(!!@message_has_plain_query.execute(id).next) || message(id)["type"] == "text/plain"
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def message_parts(id)
|
|
242
|
+
@message_parts_query ||= db.prepare "SELECT cid, type, filename, size FROM message_part WHERE message_id = ? ORDER BY filename ASC"
|
|
243
|
+
@message_parts_query.execute(id).map do |row|
|
|
244
|
+
Hash[@message_parts_query.columns.zip(row)]
|
|
245
|
+
end
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
def message_attachments(id)
|
|
249
|
+
@message_attachments_query ||= db.prepare "SELECT cid, type, filename, size FROM message_part WHERE message_id = ? AND is_attachment = 1 ORDER BY filename ASC"
|
|
250
|
+
@message_attachments_query.execute(id).map do |row|
|
|
251
|
+
Hash[@message_attachments_query.columns.zip(row)]
|
|
252
|
+
end
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
def message_part(message_id, part_id)
|
|
256
|
+
@message_part_query ||= db.prepare "SELECT * FROM message_part WHERE message_id = ? AND id = ? LIMIT 1"
|
|
257
|
+
row = @message_part_query.execute(message_id, part_id).next
|
|
258
|
+
row && Hash[@message_part_query.columns.zip(row)]
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
def message_part_type(message_id, part_type)
|
|
262
|
+
@message_part_type_query ||= db.prepare "SELECT * FROM message_part WHERE message_id = ? AND type = ? AND is_attachment = 0 LIMIT 1"
|
|
263
|
+
row = @message_part_type_query.execute(message_id, part_type).next
|
|
264
|
+
row && Hash[@message_part_type_query.columns.zip(row)]
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
def message_part_html(message_id)
|
|
268
|
+
part = message_part_type(message_id, "text/html")
|
|
269
|
+
part ||= message_part_type(message_id, "application/xhtml+xml")
|
|
270
|
+
part ||= begin
|
|
271
|
+
message = message(message_id)
|
|
272
|
+
message if message and ["text/html", "application/xhtml+xml"].include? message["type"]
|
|
273
|
+
end
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
def message_part_plain(message_id)
|
|
277
|
+
message_part_type message_id, "text/plain"
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
def message_part_cid(message_id, cid)
|
|
281
|
+
@message_part_cid_query ||= db.prepare "SELECT * FROM message_part WHERE message_id = ?"
|
|
282
|
+
@message_part_cid_query.execute(message_id).map do |row|
|
|
283
|
+
Hash[@message_part_cid_query.columns.zip(row)]
|
|
284
|
+
end.find do |part|
|
|
285
|
+
part["cid"] == cid
|
|
286
|
+
end
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
def delete!
|
|
290
|
+
@delete_all_messages_query ||= db.prepare "DELETE FROM message"
|
|
291
|
+
@delete_all_messages_query.execute
|
|
292
|
+
|
|
293
|
+
EventMachine.next_tick do
|
|
294
|
+
MailCatcher::Bus.push(type: "clear")
|
|
295
|
+
end
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
def delete_message!(message_id)
|
|
299
|
+
@delete_messages_query ||= db.prepare "DELETE FROM message WHERE id = ?"
|
|
300
|
+
@delete_messages_query.execute(message_id)
|
|
301
|
+
|
|
302
|
+
EventMachine.next_tick do
|
|
303
|
+
MailCatcher::Bus.push(type: "remove", id: message_id)
|
|
304
|
+
end
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
def delete_older_messages!(count = MailCatcher.options[:messages_limit])
|
|
308
|
+
return if count.nil?
|
|
309
|
+
@older_messages_query ||= db.prepare "SELECT id FROM message WHERE id NOT IN (SELECT id FROM message ORDER BY created_at DESC LIMIT ?)"
|
|
310
|
+
@older_messages_query.execute(count).map do |row|
|
|
311
|
+
Hash[@older_messages_query.columns.zip(row)]
|
|
312
|
+
end.each do |message|
|
|
313
|
+
delete_message!(message["id"])
|
|
314
|
+
end
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
def message_encryption_data(id)
|
|
318
|
+
source = message_source(id)
|
|
319
|
+
return {} unless source
|
|
320
|
+
|
|
321
|
+
encryption_data = {
|
|
322
|
+
smime: nil,
|
|
323
|
+
pgp: nil
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
lines = source.lines
|
|
327
|
+
lines.each_with_index do |line, index|
|
|
328
|
+
break if line.strip.empty?
|
|
329
|
+
|
|
330
|
+
# Check for S/MIME certificate headers
|
|
331
|
+
if line.match?(/^x-certificate:/i)
|
|
332
|
+
value = line.sub(/^x-certificate:\s*/i, '').strip
|
|
333
|
+
# Handle multi-line headers
|
|
334
|
+
next_index = index + 1
|
|
335
|
+
while next_index < lines.length && lines[next_index].match?(/^\s+/)
|
|
336
|
+
value += lines[next_index].strip
|
|
337
|
+
next_index += 1
|
|
338
|
+
end
|
|
339
|
+
encryption_data[:smime] = { certificate: value } if value.present?
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
# Check for S/MIME signature headers
|
|
343
|
+
if line.match?(/^x-smime-signature:/i)
|
|
344
|
+
value = line.sub(/^x-smime-signature:\s*/i, '').strip
|
|
345
|
+
# Handle multi-line headers
|
|
346
|
+
next_index = index + 1
|
|
347
|
+
while next_index < lines.length && lines[next_index].match?(/^\s+/)
|
|
348
|
+
value += lines[next_index].strip
|
|
349
|
+
next_index += 1
|
|
350
|
+
end
|
|
351
|
+
if encryption_data[:smime].nil?
|
|
352
|
+
encryption_data[:smime] = { signature: value }
|
|
353
|
+
else
|
|
354
|
+
encryption_data[:smime][:signature] = value
|
|
355
|
+
end
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
# Check for PGP key or signature headers
|
|
359
|
+
if line.match?(/^x-pgp-key:/i)
|
|
360
|
+
value = line.sub(/^x-pgp-key:\s*/i, '').strip
|
|
361
|
+
# Handle multi-line headers
|
|
362
|
+
next_index = index + 1
|
|
363
|
+
while next_index < lines.length && lines[next_index].match?(/^\s+/)
|
|
364
|
+
value += lines[next_index].strip
|
|
365
|
+
next_index += 1
|
|
366
|
+
end
|
|
367
|
+
encryption_data[:pgp] = { key: value } if value.present?
|
|
368
|
+
end
|
|
369
|
+
|
|
370
|
+
# Check for PGP signature headers
|
|
371
|
+
if line.match?(/^x-pgp-signature:/i)
|
|
372
|
+
value = line.sub(/^x-pgp-signature:\s*/i, '').strip
|
|
373
|
+
# Handle multi-line headers
|
|
374
|
+
next_index = index + 1
|
|
375
|
+
while next_index < lines.length && lines[next_index].match?(/^\s+/)
|
|
376
|
+
value += lines[next_index].strip
|
|
377
|
+
next_index += 1
|
|
378
|
+
end
|
|
379
|
+
if encryption_data[:pgp].nil?
|
|
380
|
+
encryption_data[:pgp] = { signature: value }
|
|
381
|
+
else
|
|
382
|
+
encryption_data[:pgp][:signature] = value
|
|
383
|
+
end
|
|
384
|
+
end
|
|
385
|
+
end
|
|
386
|
+
|
|
387
|
+
encryption_data
|
|
388
|
+
end
|
|
389
|
+
|
|
390
|
+
private
|
|
391
|
+
|
|
392
|
+
def parse_authentication_results(auth_header)
|
|
393
|
+
results = {
|
|
394
|
+
dmarc: nil,
|
|
395
|
+
dkim: nil,
|
|
396
|
+
spf: nil
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
# Parse DMARC result
|
|
400
|
+
if auth_header.match?(/dmarc=/i)
|
|
401
|
+
dmarc_match = auth_header.match(/dmarc=(\w+)/i)
|
|
402
|
+
results[:dmarc] = dmarc_match[1].downcase if dmarc_match
|
|
403
|
+
end
|
|
404
|
+
|
|
405
|
+
# Parse DKIM result
|
|
406
|
+
if auth_header.match?(/dkim=/i)
|
|
407
|
+
dkim_match = auth_header.match(/dkim=(\w+)/i)
|
|
408
|
+
results[:dkim] = dkim_match[1].downcase if dkim_match
|
|
409
|
+
end
|
|
410
|
+
|
|
411
|
+
# Parse SPF result
|
|
412
|
+
if auth_header.match?(/spf=/i)
|
|
413
|
+
spf_match = auth_header.match(/spf=(\w+)/i)
|
|
414
|
+
results[:spf] = spf_match[1].downcase if spf_match
|
|
415
|
+
end
|
|
416
|
+
|
|
417
|
+
results
|
|
418
|
+
end
|
|
419
|
+
end
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "eventmachine"
|
|
4
|
+
|
|
5
|
+
require "mail_catcher/mail"
|
|
6
|
+
|
|
7
|
+
class MailCatcher::Smtp < EventMachine::Protocols::SmtpServer
|
|
8
|
+
# We override EM's mail from processing to allow multiple mail-from commands
|
|
9
|
+
# per [RFC 2821](https://tools.ietf.org/html/rfc2821#section-4.1.1.2)
|
|
10
|
+
def process_mail_from sender
|
|
11
|
+
if @state.include? :mail_from
|
|
12
|
+
@state -= [:mail_from, :rcpt, :data]
|
|
13
|
+
|
|
14
|
+
receive_reset
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
super
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def current_message
|
|
21
|
+
@current_message ||= {}
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def receive_reset
|
|
25
|
+
@current_message = nil
|
|
26
|
+
|
|
27
|
+
true
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def receive_sender(sender)
|
|
31
|
+
# EventMachine SMTP advertises size extensions [https://tools.ietf.org/html/rfc1870]
|
|
32
|
+
# so strip potential " SIZE=..." suffixes from senders
|
|
33
|
+
sender = $` if sender =~ / SIZE=\d+\z/
|
|
34
|
+
|
|
35
|
+
current_message[:sender] = sender
|
|
36
|
+
|
|
37
|
+
true
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def receive_recipient(recipient)
|
|
41
|
+
current_message[:recipients] ||= []
|
|
42
|
+
current_message[:recipients] << recipient
|
|
43
|
+
|
|
44
|
+
true
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def receive_data_chunk(lines)
|
|
48
|
+
current_message[:source] ||= +""
|
|
49
|
+
|
|
50
|
+
lines.each do |line|
|
|
51
|
+
current_message[:source] << line << "\r\n"
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
true
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def receive_message
|
|
58
|
+
MailCatcher::Mail.add_message current_message
|
|
59
|
+
MailCatcher::Mail.delete_older_messages!
|
|
60
|
+
puts "==> SMTP: Received message from '#{current_message[:sender]}' (#{current_message[:source].length} bytes)"
|
|
61
|
+
true
|
|
62
|
+
rescue => exception
|
|
63
|
+
MailCatcher.log_exception("Error receiving message", @current_message, exception)
|
|
64
|
+
false
|
|
65
|
+
ensure
|
|
66
|
+
@current_message = nil
|
|
67
|
+
end
|
|
68
|
+
end
|