mailcatcher-jruby 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 +20 -0
- data/README.md +146 -0
- data/bin/catchmail +61 -0
- data/bin/mailcatcher +4 -0
- data/lib/mail_catcher.rb +220 -0
- data/lib/mail_catcher/events.rb +5 -0
- data/lib/mail_catcher/growl.rb +16 -0
- data/lib/mail_catcher/mail.rb +203 -0
- data/lib/mail_catcher/smtp.rb +57 -0
- data/lib/mail_catcher/version.rb +3 -0
- data/lib/mail_catcher/web.rb +173 -0
- data/public/favicon.ico +0 -0
- data/public/images/logo.png +0 -0
- data/public/images/logo_large.png +0 -0
- data/public/javascripts/application.js +445 -0
- data/public/javascripts/date.js +104 -0
- data/public/javascripts/flexie.min.js +36 -0
- data/public/javascripts/jquery.js +6883 -0
- data/public/javascripts/keymaster.min.js +4 -0
- data/public/javascripts/modernizr.js +3 -0
- data/public/javascripts/xslt-3.2.js +1 -0
- data/public/stylesheets/analysis.xsl +33 -0
- data/public/stylesheets/application.css +375 -0
- data/views/index.haml +64 -0
- metadata +276 -0
@@ -0,0 +1,16 @@
|
|
1
|
+
module MailCatcher
|
2
|
+
module Growl extend self
|
3
|
+
def start
|
4
|
+
MailCatcher::Events::MessageAdded.subscribe MailCatcher::Growl.method :notify
|
5
|
+
end
|
6
|
+
|
7
|
+
def notify message
|
8
|
+
image_path = File.expand_path(File.join(__FILE__, '..', '..', '..', 'public', 'images', 'logo_large.png'))
|
9
|
+
system "growlnotify", "--image", image_path, "--name", "MailCatcher", "--message", "Message received:\n#{message["subject"]}"
|
10
|
+
end
|
11
|
+
|
12
|
+
# TODO: Native support on MacRuby with click backs
|
13
|
+
#def click
|
14
|
+
#end
|
15
|
+
end
|
16
|
+
end
|
@@ -0,0 +1,203 @@
|
|
1
|
+
require 'active_support/json'
|
2
|
+
require 'active_record'
|
3
|
+
require 'mail'
|
4
|
+
#require 'sqlite3'
|
5
|
+
require 'eventmachine'
|
6
|
+
|
7
|
+
ActiveRecord::Base.configurations["db"] = { adapter: 'jdbcsqlite3', database: 'db', pool: 20 }
|
8
|
+
|
9
|
+
class Message < ActiveRecord::Base
|
10
|
+
establish_connection :db
|
11
|
+
has_many :message_parts, dependent: :destroy
|
12
|
+
self.inheritance_column = nil
|
13
|
+
|
14
|
+
def recipients
|
15
|
+
self[:recipients] &&= [ActiveSupport::JSON.decode(self[:recipients])].flatten!
|
16
|
+
end
|
17
|
+
end
|
18
|
+
class MessagePart < ActiveRecord::Base
|
19
|
+
establish_connection :db
|
20
|
+
belongs_to :message
|
21
|
+
self.inheritance_column = nil
|
22
|
+
end
|
23
|
+
|
24
|
+
|
25
|
+
Message.connection.create_table :messages do |t|
|
26
|
+
t.text :sender
|
27
|
+
t.text :recipients
|
28
|
+
t.text :subject
|
29
|
+
t.binary :source
|
30
|
+
t.text :size
|
31
|
+
t.text :type
|
32
|
+
|
33
|
+
t.timestamps
|
34
|
+
end unless Message.table_exists?
|
35
|
+
|
36
|
+
MessagePart.connection.create_table :message_parts do |t|
|
37
|
+
t.integer :message_id
|
38
|
+
t.text :cid
|
39
|
+
t.text :type
|
40
|
+
t.integer :is_attachment
|
41
|
+
t.text :filename
|
42
|
+
t.text :charset
|
43
|
+
t.binary :body
|
44
|
+
t.integer :size
|
45
|
+
end unless MessagePart.table_exists?
|
46
|
+
|
47
|
+
module MailCatcher::Mail extend self
|
48
|
+
|
49
|
+
def add_message(message)
|
50
|
+
#@add_message_query ||= db.prepare("INSERT INTO message (sender, recipients, subject, source, type, size, created_at) VALUES (?, ?, ?, ?, ?, ?, datetime('now'))")
|
51
|
+
|
52
|
+
mail = Mail.new(message[:source])
|
53
|
+
#@add_message_query.execute(message[:sender], message[:recipients].to_json, mail.subject, message[:source], mail.mime_type || 'text/plain', message[:source].length)
|
54
|
+
|
55
|
+
m = Message.create(
|
56
|
+
sender: message[:sender],
|
57
|
+
recipients: message[:recipients].to_json,
|
58
|
+
subject: mail.subject,
|
59
|
+
source: message[:source],
|
60
|
+
type: mail.mime_type || 'text/plain',
|
61
|
+
size: message[:source].length
|
62
|
+
)
|
63
|
+
|
64
|
+
#message_id = db.last_insert_row_id
|
65
|
+
message_id = m.id
|
66
|
+
parts = mail.all_parts
|
67
|
+
parts = [mail] if parts.empty?
|
68
|
+
parts.each do |part|
|
69
|
+
body = part.body.to_s
|
70
|
+
# Only parts have CIDs, not mail
|
71
|
+
cid = part.cid if part.respond_to? :cid
|
72
|
+
#add_message_part(message_id, cid, part.mime_type || 'text/plain', part.attachment? ? 1 : 0, part.filename, part.charset, body, body.length)
|
73
|
+
MessagePart.create(
|
74
|
+
message_id: message_id,
|
75
|
+
cid: cid,
|
76
|
+
type: part.mime_type || 'text/plain',
|
77
|
+
is_attachment: part.attachment? ? 1 : 0,
|
78
|
+
filename: part.filename,
|
79
|
+
charset: part.charset,
|
80
|
+
body: body,
|
81
|
+
size: body.length
|
82
|
+
)
|
83
|
+
end
|
84
|
+
|
85
|
+
EventMachine.next_tick do
|
86
|
+
message = MailCatcher::Mail.message message_id
|
87
|
+
MailCatcher::Events::MessageAdded.push message
|
88
|
+
end
|
89
|
+
end
|
90
|
+
|
91
|
+
#def add_message_part(*args)
|
92
|
+
#@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'))"
|
93
|
+
#@add_message_part_query.execute(*args)
|
94
|
+
#end
|
95
|
+
|
96
|
+
#def latest_created_at
|
97
|
+
#@latest_created_at_query ||= db.prepare "SELECT created_at FROM message ORDER BY created_at DESC LIMIT 1"
|
98
|
+
#@latest_created_at_query.execute.next
|
99
|
+
#end
|
100
|
+
|
101
|
+
def messages
|
102
|
+
Message.all.map(&:attributes)
|
103
|
+
#@messages_query ||= db.prepare "SELECT id, sender, recipients, subject, size, created_at FROM message ORDER BY created_at ASC"
|
104
|
+
#@messages_query.execute.map do |row|
|
105
|
+
#Hash[row.fields.zip(row)].tap do |message|
|
106
|
+
#message["recipients"] &&= ActiveSupport::JSON.decode message["recipients"]
|
107
|
+
#end
|
108
|
+
#end
|
109
|
+
end
|
110
|
+
|
111
|
+
def message(id)
|
112
|
+
Message.find(id).attributes
|
113
|
+
#@message_query ||= db.prepare "SELECT * FROM message WHERE id = ? LIMIT 1"
|
114
|
+
#row = @message_query.execute(id).next
|
115
|
+
#row && Hash[row.fields.zip(row)].tap do |message|
|
116
|
+
#message["recipients"] &&= ActiveSupport::JSON.decode message["recipients"]
|
117
|
+
#end
|
118
|
+
end
|
119
|
+
|
120
|
+
def message_has_html?(id)
|
121
|
+
part = MessagePart.where(message_id: id, is_attachment: 0).where("type IN ('application/xhtml+xml', 'text/html')").first
|
122
|
+
part.present? || ['text/html', 'application/xhtml+xml'].include?(Message.find(id).type)
|
123
|
+
#@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"
|
124
|
+
#(!!@message_has_html_query.execute(id).next) || ['text/html', 'application/xhtml+xml'].include?(message(id)["type"])
|
125
|
+
end
|
126
|
+
|
127
|
+
def message_has_plain?(id)
|
128
|
+
part = MessagePart.where(message_id: id, is_attachment: 0, type: 'text/plain').first
|
129
|
+
part.present? || Message.find(id).type == 'text/plain'
|
130
|
+
#@message_has_plain_query ||= db.prepare "SELECT 1 FROM message_part WHERE message_id = ? AND is_attachment = 0 AND type = 'text/plain' LIMIT 1"
|
131
|
+
#(!!@message_has_plain_query.execute(id).next) || message(id)["type"] == "text/plain"
|
132
|
+
end
|
133
|
+
|
134
|
+
def message_parts(id)
|
135
|
+
MessagePart.where(:message_id => id).order('filename ASC').map(&:attributes)
|
136
|
+
#@message_parts_query ||= db.prepare "SELECT cid, type, filename, size FROM message_part WHERE message_id = ? ORDER BY filename ASC"
|
137
|
+
#@message_parts_query.execute(id).map do |row|
|
138
|
+
#Hash[row.fields.zip(row)]
|
139
|
+
#end
|
140
|
+
end
|
141
|
+
|
142
|
+
def message_attachments(id)
|
143
|
+
MessagePart.where(message_id: id, is_attachment: 1).order('filename ASC').map(&:attributes)
|
144
|
+
#@message_parts_query ||= db.prepare "SELECT cid, type, filename, size FROM message_part WHERE message_id = ? AND is_attachment = 1 ORDER BY filename ASC"
|
145
|
+
#@message_parts_query.execute(id).map do |row|
|
146
|
+
#Hash[row.fields.zip(row)]
|
147
|
+
#end
|
148
|
+
end
|
149
|
+
|
150
|
+
def message_part(message_id, part_id)
|
151
|
+
MessagePart.where(message_id: message_id, id: part_id).first.try(:attributes)
|
152
|
+
#@message_part_query ||= db.prepare "SELECT * FROM message_part WHERE message_id = ? AND id = ? LIMIT 1"
|
153
|
+
#row = @message_part_query.execute(message_id, part_id).next
|
154
|
+
#row && Hash[row.fields.zip(row)]
|
155
|
+
end
|
156
|
+
|
157
|
+
def message_part_type(message_id, part_type)
|
158
|
+
MessagePart.where(message_id: message_id, type: part_type, is_attachment: 0).first.try(:attributes)
|
159
|
+
#@message_part_type_query ||= db.prepare "SELECT * FROM message_part WHERE message_id = ? AND type = ? AND is_attachment = 0 LIMIT 1"
|
160
|
+
#row = @message_part_type_query.execute(message_id, part_type).next
|
161
|
+
#row && Hash[row.fields.zip(row)]
|
162
|
+
end
|
163
|
+
|
164
|
+
def message_part_html(message_id)
|
165
|
+
part = message_part_type(message_id, "text/html")
|
166
|
+
part ||= message_part_type(message_id, "application/xhtml+xml")
|
167
|
+
part ||= begin
|
168
|
+
message = message(message_id)
|
169
|
+
message if message.present? and ['text/html', 'application/xhtml+xml'].include? message["type"]
|
170
|
+
end
|
171
|
+
end
|
172
|
+
|
173
|
+
def message_part_plain(message_id)
|
174
|
+
message_part_type message_id, "text/plain"
|
175
|
+
end
|
176
|
+
|
177
|
+
def message_part_cid(message_id, cid)
|
178
|
+
MessagePart.where(message_id: message_id, cid: cid).first.try(:attributes)
|
179
|
+
#@message_part_cid_query ||= db.prepare 'SELECT * FROM message_part WHERE message_id = ?'
|
180
|
+
#@message_part_cid_query.execute(message_id).map do |row|
|
181
|
+
#Hash[row.fields.zip(row)]
|
182
|
+
#end.find do |part|
|
183
|
+
#part["cid"] == cid
|
184
|
+
#end
|
185
|
+
end
|
186
|
+
|
187
|
+
def delete!
|
188
|
+
Message.destroy_all
|
189
|
+
#@delete_messages_query ||= db.prepare 'DELETE FROM message'
|
190
|
+
#@delete_message_parts_query ||= db.prepare 'DELETE FROM message_part'
|
191
|
+
|
192
|
+
#@delete_messages_query.execute and
|
193
|
+
#@delete_message_parts_query.execute
|
194
|
+
end
|
195
|
+
|
196
|
+
def delete_message!(message_id)
|
197
|
+
Message.where(id: message_id).destroy_all
|
198
|
+
#@delete_messages_query ||= db.prepare 'DELETE FROM message WHERE id = ?'
|
199
|
+
#@delete_message_parts_query ||= db.prepare 'DELETE FROM message_part WHERE message_id = ?'
|
200
|
+
#@delete_messages_query.execute(message_id) and
|
201
|
+
#@delete_message_parts_query.execute(message_id)
|
202
|
+
end
|
203
|
+
end
|
@@ -0,0 +1,57 @@
|
|
1
|
+
require 'eventmachine'
|
2
|
+
|
3
|
+
class MailCatcher::Smtp < EventMachine::Protocols::SmtpServer
|
4
|
+
# We override EM's mail from processing to allow multiple mail-from commands
|
5
|
+
# per [RFC 2821](http://tools.ietf.org/html/rfc2821#section-4.1.1.2)
|
6
|
+
def process_mail_from sender
|
7
|
+
if @state.include? :mail_from
|
8
|
+
@state -= [:mail_from, :rcpt, :data]
|
9
|
+
receive_reset
|
10
|
+
end
|
11
|
+
|
12
|
+
super
|
13
|
+
end
|
14
|
+
|
15
|
+
def current_message
|
16
|
+
@current_message ||= {}
|
17
|
+
end
|
18
|
+
|
19
|
+
def receive_reset
|
20
|
+
@current_message = nil
|
21
|
+
true
|
22
|
+
end
|
23
|
+
|
24
|
+
def receive_sender(sender)
|
25
|
+
current_message[:sender] = sender
|
26
|
+
true
|
27
|
+
end
|
28
|
+
|
29
|
+
def receive_recipient(recipient)
|
30
|
+
current_message[:recipients] ||= []
|
31
|
+
current_message[:recipients] << recipient
|
32
|
+
true
|
33
|
+
end
|
34
|
+
|
35
|
+
def receive_data_chunk(lines)
|
36
|
+
current_message[:source] ||= ""
|
37
|
+
current_message[:source] << lines.join("\n")
|
38
|
+
true
|
39
|
+
end
|
40
|
+
|
41
|
+
def receive_message
|
42
|
+
MailCatcher::Mail.add_message current_message
|
43
|
+
puts "==> SMTP: Received message from '#{current_message[:sender]}' (#{current_message[:source].length} bytes)"
|
44
|
+
true
|
45
|
+
rescue
|
46
|
+
puts "*** Error receiving message: #{current_message.inspect}"
|
47
|
+
puts " Exception: #{$!}"
|
48
|
+
puts " Backtrace:"
|
49
|
+
$!.backtrace.each do |line|
|
50
|
+
puts " #{line}"
|
51
|
+
end
|
52
|
+
puts " Please submit this as an issue at http://github.com/sj26/mailcatcher/issues"
|
53
|
+
false
|
54
|
+
ensure
|
55
|
+
@current_message = nil
|
56
|
+
end
|
57
|
+
end
|
@@ -0,0 +1,173 @@
|
|
1
|
+
require 'sinatra'
|
2
|
+
require 'pathname'
|
3
|
+
require 'net/http'
|
4
|
+
require 'uri'
|
5
|
+
#require 'skinny'
|
6
|
+
|
7
|
+
|
8
|
+
# #-=--------------------
|
9
|
+
# module MailCatcher
|
10
|
+
# end
|
11
|
+
|
12
|
+
# DB_CONF = { adapter: 'jdbcsqlite3', database: 'db' }
|
13
|
+
|
14
|
+
# class Message < ActiveRecord::Base
|
15
|
+
# establish_connection DB_CONF
|
16
|
+
# has_many :message_parts, dependent: :destroy
|
17
|
+
# self.inheritance_column = nil
|
18
|
+
|
19
|
+
# def recipients
|
20
|
+
# self[:recipients] &&= ActiveSupport::JSON.decode self[:recipients]
|
21
|
+
# end
|
22
|
+
# end
|
23
|
+
# class MessagePart < ActiveRecord::Base
|
24
|
+
# establish_connection DB_CONF
|
25
|
+
# belongs_to :message
|
26
|
+
# self.inheritance_column = nil
|
27
|
+
# end
|
28
|
+
|
29
|
+
# #---------------------------
|
30
|
+
|
31
|
+
|
32
|
+
class Sinatra::Request
|
33
|
+
#include Skinny::Helpers
|
34
|
+
end
|
35
|
+
|
36
|
+
class MailCatcher::Web < Sinatra::Base
|
37
|
+
set :root, File.expand_path("#{__FILE__}/../../..")
|
38
|
+
set :haml, :format => :html5
|
39
|
+
|
40
|
+
get '/' do
|
41
|
+
haml :index
|
42
|
+
end
|
43
|
+
|
44
|
+
delete '/' do
|
45
|
+
if MailCatcher.quittable?
|
46
|
+
MailCatcher.quit!
|
47
|
+
status 204
|
48
|
+
else
|
49
|
+
status 403
|
50
|
+
end
|
51
|
+
end
|
52
|
+
|
53
|
+
get '/messages' do
|
54
|
+
# if request.websocket?
|
55
|
+
# request.websocket!(
|
56
|
+
# :on_start => proc do |websocket|
|
57
|
+
# subscription = MailCatcher::Events::MessageAdded.subscribe { |message| websocket.send_message message.to_json }
|
58
|
+
# websocket.on_close do |websocket|
|
59
|
+
# MailCatcher::Events::MessageAdded.unsubscribe subscription
|
60
|
+
# end
|
61
|
+
# end)
|
62
|
+
# else
|
63
|
+
MailCatcher::Mail.messages.to_json
|
64
|
+
# end
|
65
|
+
end
|
66
|
+
|
67
|
+
delete '/messages' do
|
68
|
+
MailCatcher::Mail.delete!
|
69
|
+
status 204
|
70
|
+
end
|
71
|
+
|
72
|
+
get '/messages/:id.json' do
|
73
|
+
id = params[:id].to_i
|
74
|
+
if message = MailCatcher::Mail.message(id)
|
75
|
+
message.merge({
|
76
|
+
"formats" => [
|
77
|
+
"source",
|
78
|
+
("html" if MailCatcher::Mail.message_has_html? id),
|
79
|
+
("plain" if MailCatcher::Mail.message_has_plain? id)
|
80
|
+
].compact,
|
81
|
+
"attachments" => MailCatcher::Mail.message_attachments(id).map do |attachment|
|
82
|
+
attachment.merge({"href" => "/messages/#{escape(id)}/parts/#{escape(attachment['cid'])}"})
|
83
|
+
end,
|
84
|
+
}).to_json
|
85
|
+
else
|
86
|
+
not_found
|
87
|
+
end
|
88
|
+
end
|
89
|
+
|
90
|
+
get '/messages/:id.html' do
|
91
|
+
id = params[:id].to_i
|
92
|
+
if part = MailCatcher::Mail.message_part_html(id)
|
93
|
+
content_type part["type"], :charset => (part["charset"] || "utf8")
|
94
|
+
|
95
|
+
body = part["body"]
|
96
|
+
|
97
|
+
# Rewrite body to link to embedded attachments served by cid
|
98
|
+
body.gsub! /cid:([^'"> ]+)/, "#{id}/parts/\\1"
|
99
|
+
|
100
|
+
body
|
101
|
+
else
|
102
|
+
not_found
|
103
|
+
end
|
104
|
+
end
|
105
|
+
|
106
|
+
get "/messages/:id.plain" do
|
107
|
+
id = params[:id].to_i
|
108
|
+
if part = MailCatcher::Mail.message_part_plain(id)
|
109
|
+
content_type part["type"], :charset => (part["charset"] || "utf8")
|
110
|
+
part["body"]
|
111
|
+
else
|
112
|
+
not_found
|
113
|
+
end
|
114
|
+
end
|
115
|
+
|
116
|
+
get "/messages/:id.source" do
|
117
|
+
id = params[:id].to_i
|
118
|
+
if message = MailCatcher::Mail.message(id)
|
119
|
+
content_type "text/plain"
|
120
|
+
message["source"]
|
121
|
+
else
|
122
|
+
not_found
|
123
|
+
end
|
124
|
+
end
|
125
|
+
|
126
|
+
get "/messages/:id.eml" do
|
127
|
+
id = params[:id].to_i
|
128
|
+
if message = MailCatcher::Mail.message(id)
|
129
|
+
content_type "message/rfc822"
|
130
|
+
message["source"]
|
131
|
+
else
|
132
|
+
not_found
|
133
|
+
end
|
134
|
+
end
|
135
|
+
|
136
|
+
get "/messages/:id/parts/:cid" do
|
137
|
+
id = params[:id].to_i
|
138
|
+
if part = MailCatcher::Mail.message_part_cid(id, params[:cid])
|
139
|
+
content_type part["type"], :charset => (part["charset"] || "utf8")
|
140
|
+
attachment part["filename"] if part["is_attachment"] == 1
|
141
|
+
body part["body"].to_s
|
142
|
+
else
|
143
|
+
not_found
|
144
|
+
end
|
145
|
+
end
|
146
|
+
|
147
|
+
get "/messages/:id/analysis.?:format?" do
|
148
|
+
id = params[:id].to_i
|
149
|
+
if part = MailCatcher::Mail.message_part_html(id)
|
150
|
+
# TODO: Server-side cache? Make the browser cache based on message create time? Hmm.
|
151
|
+
uri = URI.parse("http://api.getfractal.com/api/v2/validate#{"/format/#{params[:format]}" if params[:format].present?}")
|
152
|
+
response = Net::HTTP.post_form(uri, :api_key => "5c463877265251386f516f7428", :html => part["body"])
|
153
|
+
content_type ".#{params[:format]}" if params[:format].present?
|
154
|
+
body response.body
|
155
|
+
else
|
156
|
+
not_found
|
157
|
+
end
|
158
|
+
end
|
159
|
+
|
160
|
+
delete '/messages/:id' do
|
161
|
+
id = params[:id].to_i
|
162
|
+
if message = MailCatcher::Mail.message(id)
|
163
|
+
MailCatcher::Mail.delete_message!(id)
|
164
|
+
status 204
|
165
|
+
else
|
166
|
+
not_found
|
167
|
+
end
|
168
|
+
end
|
169
|
+
|
170
|
+
not_found do
|
171
|
+
"<html><body><h1>No Dice</h1><p>The message you were looking for does not exist, or doesn't have content of this type.</p></body></html>"
|
172
|
+
end
|
173
|
+
end
|