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.
@@ -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
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MailCatcher
4
+ VERSION = '1.0.0'
5
+ end