danxexe-mailcatcher 0.6.2
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 +123 -0
- data/bin/catchmail +71 -0
- data/bin/mailcatcher +5 -0
- data/lib/mail_catcher.rb +213 -0
- data/lib/mail_catcher/events.rb +7 -0
- data/lib/mail_catcher/mail.rb +161 -0
- data/lib/mail_catcher/smtp.rb +61 -0
- data/lib/mail_catcher/version.rb +3 -0
- data/lib/mail_catcher/web.rb +21 -0
- data/lib/mail_catcher/web/application.rb +188 -0
- data/lib/mailcatcher.rb +3 -0
- data/public/assets/logo.png +0 -0
- data/public/assets/logo_large.png +0 -0
- data/public/assets/mailcatcher.css +1 -0
- data/public/assets/mailcatcher.js +5 -0
- data/public/favicon.ico +0 -0
- data/views/404.erb +6 -0
- data/views/index.erb +62 -0
- metadata +330 -0
@@ -0,0 +1,161 @@
|
|
1
|
+
require "active_support/json"
|
2
|
+
require "eventmachine"
|
3
|
+
require "mail"
|
4
|
+
require "sqlite3"
|
5
|
+
|
6
|
+
module MailCatcher::Mail extend self
|
7
|
+
def db
|
8
|
+
@__db ||= begin
|
9
|
+
SQLite3::Database.new(":memory:", :type_translation => true).tap do |db|
|
10
|
+
db.execute(<<-SQL)
|
11
|
+
CREATE TABLE message (
|
12
|
+
id INTEGER PRIMARY KEY ASC,
|
13
|
+
sender TEXT,
|
14
|
+
recipients TEXT,
|
15
|
+
subject TEXT,
|
16
|
+
source BLOB,
|
17
|
+
size TEXT,
|
18
|
+
type TEXT,
|
19
|
+
created_at DATETIME DEFAULT CURRENT_DATETIME
|
20
|
+
)
|
21
|
+
SQL
|
22
|
+
db.execute(<<-SQL)
|
23
|
+
CREATE TABLE message_part (
|
24
|
+
id INTEGER PRIMARY KEY ASC,
|
25
|
+
message_id INTEGER NOT NULL,
|
26
|
+
cid TEXT,
|
27
|
+
type TEXT,
|
28
|
+
is_attachment INTEGER,
|
29
|
+
filename TEXT,
|
30
|
+
charset TEXT,
|
31
|
+
body BLOB,
|
32
|
+
size INTEGER,
|
33
|
+
created_at DATETIME DEFAULT CURRENT_DATETIME
|
34
|
+
)
|
35
|
+
SQL
|
36
|
+
end
|
37
|
+
end
|
38
|
+
end
|
39
|
+
|
40
|
+
def add_message(message)
|
41
|
+
@add_message_query ||= db.prepare("INSERT INTO message (sender, recipients, subject, source, type, size, created_at) VALUES (?, ?, ?, ?, ?, ?, datetime('now'))")
|
42
|
+
|
43
|
+
mail = Mail.new(message[:source])
|
44
|
+
@add_message_query.execute(message[:sender], message[:recipients].to_json, mail.subject, message[:source], mail.mime_type || "text/plain", message[:source].length)
|
45
|
+
message_id = db.last_insert_row_id
|
46
|
+
parts = mail.all_parts
|
47
|
+
parts = [mail] if parts.empty?
|
48
|
+
parts.each do |part|
|
49
|
+
body = part.body.to_s
|
50
|
+
# Only parts have CIDs, not mail
|
51
|
+
cid = part.cid if part.respond_to? :cid
|
52
|
+
add_message_part(message_id, cid, part.mime_type || "text/plain", part.attachment? ? 1 : 0, part.filename, part.charset, body, body.length)
|
53
|
+
end
|
54
|
+
|
55
|
+
EventMachine.next_tick do
|
56
|
+
message = MailCatcher::Mail.message message_id
|
57
|
+
MailCatcher::Events::MessageAdded.push message
|
58
|
+
end
|
59
|
+
end
|
60
|
+
|
61
|
+
def add_message_part(*args)
|
62
|
+
@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'))"
|
63
|
+
@add_message_part_query.execute(*args)
|
64
|
+
end
|
65
|
+
|
66
|
+
def latest_created_at
|
67
|
+
@latest_created_at_query ||= db.prepare "SELECT created_at FROM message ORDER BY created_at DESC LIMIT 1"
|
68
|
+
@latest_created_at_query.execute.next
|
69
|
+
end
|
70
|
+
|
71
|
+
def messages
|
72
|
+
@messages_query ||= db.prepare "SELECT id, sender, recipients, subject, size, created_at FROM message ORDER BY created_at, id ASC"
|
73
|
+
@messages_query.execute.map do |row|
|
74
|
+
Hash[row.fields.zip(row)].tap do |message|
|
75
|
+
message["recipients"] &&= ActiveSupport::JSON.decode message["recipients"]
|
76
|
+
end
|
77
|
+
end
|
78
|
+
end
|
79
|
+
|
80
|
+
def message(id)
|
81
|
+
@message_query ||= db.prepare "SELECT * FROM message WHERE id = ? LIMIT 1"
|
82
|
+
row = @message_query.execute(id).next
|
83
|
+
row && Hash[row.fields.zip(row)].tap do |message|
|
84
|
+
message["recipients"] &&= ActiveSupport::JSON.decode message["recipients"]
|
85
|
+
message["source"].force_encoding('UTF-8')
|
86
|
+
end
|
87
|
+
end
|
88
|
+
|
89
|
+
def message_has_html?(id)
|
90
|
+
@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"
|
91
|
+
(!!@message_has_html_query.execute(id).next) || ["text/html", "application/xhtml+xml"].include?(message(id)["type"])
|
92
|
+
end
|
93
|
+
|
94
|
+
def message_has_plain?(id)
|
95
|
+
@message_has_plain_query ||= db.prepare "SELECT 1 FROM message_part WHERE message_id = ? AND is_attachment = 0 AND type = 'text/plain' LIMIT 1"
|
96
|
+
(!!@message_has_plain_query.execute(id).next) || message(id)["type"] == "text/plain"
|
97
|
+
end
|
98
|
+
|
99
|
+
def message_parts(id)
|
100
|
+
@message_parts_query ||= db.prepare "SELECT cid, type, filename, size FROM message_part WHERE message_id = ? ORDER BY filename ASC"
|
101
|
+
@message_parts_query.execute(id).map do |row|
|
102
|
+
Hash[row.fields.zip(row)]
|
103
|
+
end
|
104
|
+
end
|
105
|
+
|
106
|
+
def message_attachments(id)
|
107
|
+
@message_parts_query ||= db.prepare "SELECT cid, type, filename, size FROM message_part WHERE message_id = ? AND is_attachment = 1 ORDER BY filename ASC"
|
108
|
+
@message_parts_query.execute(id).map do |row|
|
109
|
+
Hash[row.fields.zip(row)]
|
110
|
+
end
|
111
|
+
end
|
112
|
+
|
113
|
+
def message_part(message_id, part_id)
|
114
|
+
@message_part_query ||= db.prepare "SELECT * FROM message_part WHERE message_id = ? AND id = ? LIMIT 1"
|
115
|
+
row = @message_part_query.execute(message_id, part_id).next
|
116
|
+
row && Hash[row.fields.zip(row)]
|
117
|
+
end
|
118
|
+
|
119
|
+
def message_part_type(message_id, part_type)
|
120
|
+
@message_part_type_query ||= db.prepare "SELECT * FROM message_part WHERE message_id = ? AND type = ? AND is_attachment = 0 LIMIT 1"
|
121
|
+
row = @message_part_type_query.execute(message_id, part_type).next
|
122
|
+
row && Hash[row.fields.zip(row)]
|
123
|
+
end
|
124
|
+
|
125
|
+
def message_part_html(message_id)
|
126
|
+
part = message_part_type(message_id, "text/html")
|
127
|
+
part ||= message_part_type(message_id, "application/xhtml+xml")
|
128
|
+
part ||= begin
|
129
|
+
message = message(message_id)
|
130
|
+
message if message.present? and ["text/html", "application/xhtml+xml"].include? message["type"]
|
131
|
+
end
|
132
|
+
end
|
133
|
+
|
134
|
+
def message_part_plain(message_id)
|
135
|
+
message_part_type message_id, "text/plain"
|
136
|
+
end
|
137
|
+
|
138
|
+
def message_part_cid(message_id, cid)
|
139
|
+
@message_part_cid_query ||= db.prepare "SELECT * FROM message_part WHERE message_id = ?"
|
140
|
+
@message_part_cid_query.execute(message_id).map do |row|
|
141
|
+
Hash[row.fields.zip(row)]
|
142
|
+
end.find do |part|
|
143
|
+
part["cid"] == cid
|
144
|
+
end
|
145
|
+
end
|
146
|
+
|
147
|
+
def delete!
|
148
|
+
@delete_messages_query ||= db.prepare "DELETE FROM message"
|
149
|
+
@delete_message_parts_query ||= db.prepare "DELETE FROM message_part"
|
150
|
+
|
151
|
+
@delete_messages_query.execute and
|
152
|
+
@delete_message_parts_query.execute
|
153
|
+
end
|
154
|
+
|
155
|
+
def delete_message!(message_id)
|
156
|
+
@delete_messages_query ||= db.prepare "DELETE FROM message WHERE id = ?"
|
157
|
+
@delete_message_parts_query ||= db.prepare "DELETE FROM message_part WHERE message_id = ?"
|
158
|
+
@delete_messages_query.execute(message_id) and
|
159
|
+
@delete_message_parts_query.execute(message_id)
|
160
|
+
end
|
161
|
+
end
|
@@ -0,0 +1,61 @@
|
|
1
|
+
require "eventmachine"
|
2
|
+
|
3
|
+
require "mail_catcher/mail"
|
4
|
+
|
5
|
+
class MailCatcher::Smtp < EventMachine::Protocols::SmtpServer
|
6
|
+
# We override EM's mail from processing to allow multiple mail-from commands
|
7
|
+
# per [RFC 2821](http://tools.ietf.org/html/rfc2821#section-4.1.1.2)
|
8
|
+
def process_mail_from sender
|
9
|
+
if @state.include? :mail_from
|
10
|
+
@state -= [:mail_from, :rcpt, :data]
|
11
|
+
receive_reset
|
12
|
+
end
|
13
|
+
|
14
|
+
super
|
15
|
+
end
|
16
|
+
|
17
|
+
def current_message
|
18
|
+
@current_message ||= {}
|
19
|
+
end
|
20
|
+
|
21
|
+
def receive_reset
|
22
|
+
@current_message = nil
|
23
|
+
true
|
24
|
+
end
|
25
|
+
|
26
|
+
def receive_sender(sender)
|
27
|
+
current_message[:sender] = sender
|
28
|
+
true
|
29
|
+
end
|
30
|
+
|
31
|
+
def receive_recipient(recipient)
|
32
|
+
current_message[:recipients] ||= []
|
33
|
+
current_message[:recipients] << recipient
|
34
|
+
true
|
35
|
+
end
|
36
|
+
|
37
|
+
def receive_data_chunk(lines)
|
38
|
+
current_message[:source] ||= ""
|
39
|
+
lines.each do |line|
|
40
|
+
current_message[:source] << line << "\r\n"
|
41
|
+
end
|
42
|
+
true
|
43
|
+
end
|
44
|
+
|
45
|
+
def receive_message
|
46
|
+
MailCatcher::Mail.add_message current_message
|
47
|
+
puts "==> SMTP: Received message from '#{current_message[:sender]}' (#{current_message[:source].length} bytes)"
|
48
|
+
true
|
49
|
+
rescue
|
50
|
+
puts "*** Error receiving message: #{current_message.inspect}"
|
51
|
+
puts " Exception: #{$!}"
|
52
|
+
puts " Backtrace:"
|
53
|
+
$!.backtrace.each do |line|
|
54
|
+
puts " #{line}"
|
55
|
+
end
|
56
|
+
puts " Please submit this as an issue at http://github.com/sj26/mailcatcher/issues"
|
57
|
+
false
|
58
|
+
ensure
|
59
|
+
@current_message = nil
|
60
|
+
end
|
61
|
+
end
|
@@ -0,0 +1,21 @@
|
|
1
|
+
require "active_support/core_ext/module/delegation"
|
2
|
+
require "rack/builder"
|
3
|
+
|
4
|
+
require "mail_catcher/web/application"
|
5
|
+
|
6
|
+
module MailCatcher
|
7
|
+
module Web extend self
|
8
|
+
def app
|
9
|
+
@@app ||= Rack::Builder.new do
|
10
|
+
if ENV["MAILCATCHER_ENV"] == "development"
|
11
|
+
require "mail_catcher/web/assets"
|
12
|
+
map("/assets") { run Assets }
|
13
|
+
end
|
14
|
+
|
15
|
+
map("/") { run Application }
|
16
|
+
end
|
17
|
+
end
|
18
|
+
|
19
|
+
delegate :call, :to => :app
|
20
|
+
end
|
21
|
+
end
|
@@ -0,0 +1,188 @@
|
|
1
|
+
require "pathname"
|
2
|
+
require "net/http"
|
3
|
+
require "uri"
|
4
|
+
|
5
|
+
require "sinatra"
|
6
|
+
require "skinny"
|
7
|
+
|
8
|
+
require "mail_catcher/events"
|
9
|
+
require "mail_catcher/mail"
|
10
|
+
|
11
|
+
class Sinatra::Request
|
12
|
+
include Skinny::Helpers
|
13
|
+
end
|
14
|
+
|
15
|
+
module MailCatcher
|
16
|
+
module Web
|
17
|
+
class Application < Sinatra::Base
|
18
|
+
set :development, ENV["MAILCATCHER_ENV"] == "development"
|
19
|
+
set :root, File.expand_path("#{__FILE__}/../../../..")
|
20
|
+
|
21
|
+
if development?
|
22
|
+
require "sprockets-helpers"
|
23
|
+
|
24
|
+
configure do
|
25
|
+
require "mail_catcher/web/assets"
|
26
|
+
Sprockets::Helpers.configure do |config|
|
27
|
+
config.environment = Assets
|
28
|
+
config.prefix = "/assets"
|
29
|
+
config.digest = false
|
30
|
+
config.public_path = public_folder
|
31
|
+
config.debug = true
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
helpers do
|
36
|
+
include Sprockets::Helpers
|
37
|
+
end
|
38
|
+
else
|
39
|
+
helpers do
|
40
|
+
def javascript_tag(name)
|
41
|
+
%{<script src="/assets/#{name}.js"></script>}
|
42
|
+
end
|
43
|
+
|
44
|
+
def stylesheet_tag(name)
|
45
|
+
%{<link rel="stylesheet" href="/assets/#{name}.css">}
|
46
|
+
end
|
47
|
+
end
|
48
|
+
end
|
49
|
+
|
50
|
+
get "/" do
|
51
|
+
erb :index
|
52
|
+
end
|
53
|
+
|
54
|
+
delete "/" do
|
55
|
+
if MailCatcher.quittable?
|
56
|
+
MailCatcher.quit!
|
57
|
+
status 204
|
58
|
+
else
|
59
|
+
status 403
|
60
|
+
end
|
61
|
+
end
|
62
|
+
|
63
|
+
get "/messages" do
|
64
|
+
if request.websocket?
|
65
|
+
request.websocket!(
|
66
|
+
:on_start => proc do |websocket|
|
67
|
+
subscription = Events::MessageAdded.subscribe { |message| websocket.send_message message.to_json }
|
68
|
+
websocket.on_close do |websocket|
|
69
|
+
Events::MessageAdded.unsubscribe subscription
|
70
|
+
end
|
71
|
+
end)
|
72
|
+
else
|
73
|
+
content_type :json
|
74
|
+
Mail.messages.to_json
|
75
|
+
end
|
76
|
+
end
|
77
|
+
|
78
|
+
delete "/messages" do
|
79
|
+
Mail.delete!
|
80
|
+
status 204
|
81
|
+
end
|
82
|
+
|
83
|
+
get "/messages/:id.json" do
|
84
|
+
id = params[:id].to_i
|
85
|
+
if message = Mail.message(id)
|
86
|
+
content_type :json
|
87
|
+
message.merge({
|
88
|
+
"formats" => [
|
89
|
+
"source",
|
90
|
+
("html" if Mail.message_has_html? id),
|
91
|
+
("plain" if Mail.message_has_plain? id)
|
92
|
+
].compact,
|
93
|
+
"attachments" => Mail.message_attachments(id).map do |attachment|
|
94
|
+
attachment.merge({"href" => "/messages/#{escape(id)}/parts/#{escape(attachment["cid"])}"})
|
95
|
+
end,
|
96
|
+
}).to_json
|
97
|
+
else
|
98
|
+
not_found
|
99
|
+
end
|
100
|
+
end
|
101
|
+
|
102
|
+
get "/messages/:id.html" do
|
103
|
+
id = params[:id].to_i
|
104
|
+
if part = Mail.message_part_html(id)
|
105
|
+
content_type part["type"], :charset => (part["charset"] || "utf8")
|
106
|
+
|
107
|
+
body = part["body"]
|
108
|
+
|
109
|
+
# Rewrite body to link to embedded attachments served by cid
|
110
|
+
body.gsub! /cid:([^'"> ]+)/, "#{id}/parts/\\1"
|
111
|
+
|
112
|
+
content_type :html
|
113
|
+
body
|
114
|
+
else
|
115
|
+
not_found
|
116
|
+
end
|
117
|
+
end
|
118
|
+
|
119
|
+
get "/messages/:id.plain" do
|
120
|
+
id = params[:id].to_i
|
121
|
+
if part = Mail.message_part_plain(id)
|
122
|
+
content_type part["type"], :charset => (part["charset"] || "utf8")
|
123
|
+
part["body"]
|
124
|
+
else
|
125
|
+
not_found
|
126
|
+
end
|
127
|
+
end
|
128
|
+
|
129
|
+
get "/messages/:id.source" do
|
130
|
+
id = params[:id].to_i
|
131
|
+
if message = Mail.message(id)
|
132
|
+
content_type "text/plain"
|
133
|
+
message["source"]
|
134
|
+
else
|
135
|
+
not_found
|
136
|
+
end
|
137
|
+
end
|
138
|
+
|
139
|
+
get "/messages/:id.eml" do
|
140
|
+
id = params[:id].to_i
|
141
|
+
if message = Mail.message(id)
|
142
|
+
content_type "message/rfc822"
|
143
|
+
message["source"]
|
144
|
+
else
|
145
|
+
not_found
|
146
|
+
end
|
147
|
+
end
|
148
|
+
|
149
|
+
get "/messages/:id/parts/:cid" do
|
150
|
+
id = params[:id].to_i
|
151
|
+
if part = Mail.message_part_cid(id, params[:cid])
|
152
|
+
content_type part["type"], :charset => (part["charset"] || "utf8")
|
153
|
+
attachment part["filename"] if part["is_attachment"] == 1
|
154
|
+
body part["body"].to_s
|
155
|
+
else
|
156
|
+
not_found
|
157
|
+
end
|
158
|
+
end
|
159
|
+
|
160
|
+
get "/messages/:id/analysis.?:format?" do
|
161
|
+
id = params[:id].to_i
|
162
|
+
if part = Mail.message_part_html(id)
|
163
|
+
# TODO: Server-side cache? Make the browser cache based on message create time? Hmm.
|
164
|
+
uri = URI.parse("http://api.getfractal.com/api/v2/validate#{"/format/#{params[:format]}" if params[:format].present?}")
|
165
|
+
response = Net::HTTP.post_form(uri, :api_key => "5c463877265251386f516f7428", :html => part["body"])
|
166
|
+
content_type ".#{params[:format]}" if params[:format].present?
|
167
|
+
body response.body
|
168
|
+
else
|
169
|
+
not_found
|
170
|
+
end
|
171
|
+
end
|
172
|
+
|
173
|
+
delete "/messages/:id" do
|
174
|
+
id = params[:id].to_i
|
175
|
+
if message = Mail.message(id)
|
176
|
+
Mail.delete_message!(id)
|
177
|
+
status 204
|
178
|
+
else
|
179
|
+
not_found
|
180
|
+
end
|
181
|
+
end
|
182
|
+
|
183
|
+
not_found do
|
184
|
+
erb :"404"
|
185
|
+
end
|
186
|
+
end
|
187
|
+
end
|
188
|
+
end
|
data/lib/mailcatcher.rb
ADDED
Binary file
|
Binary file
|