mailcatcher 0.1.6 → 0.2.0

Sign up to get free protection for your applications and to get access to all the features.
data/.gitignore ADDED
@@ -0,0 +1 @@
1
+ pkg
data/LICENSE CHANGED
@@ -1,19 +1,20 @@
1
- Copyright (c) 2010 Samuel Cochran <sj26@sj26.com>
1
+ Copyright (c) 2010 Samuel Cochran
2
2
 
3
- Permission is hereby granted, free of charge, to any person obtaining a copy
4
- of this software and associated documentation files (the "Software"), to deal
5
- in the Software without restriction, including without limitation the rights
6
- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7
- copies of the Software, and to permit persons to whom the Software is
8
- furnished to do so, subject to the following conditions:
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
9
10
 
10
- The above copyright notice and this permission notice shall be included in
11
- all copies or substantial portions of the Software.
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
12
13
 
13
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14
- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15
- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16
- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17
- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18
- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
19
- THE SOFTWARE.
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md CHANGED
@@ -32,9 +32,13 @@ MailCatcher runs a super simple SMTP server which catches any message sent to it
32
32
  * Forward mail to rendering service, maybe CampaignMonitor?
33
33
  * Package as an app? Native interfaces?
34
34
 
35
- ## License
35
+ ## Thanks
36
36
 
37
- MailCatcher is released under the MIT License. See LICENSE.
37
+ MailCatcher is just a mishmash of other people's hard work. Thank you so much to the people who have built the wonderful guts on which this project relies.
38
+
39
+ ## Copyright
40
+
41
+ Copyright (c) 2010 Samuel Cochran. See LICENSE for details.
38
42
 
39
43
  ## Dreams
40
44
 
data/Rakefile ADDED
@@ -0,0 +1,43 @@
1
+ require 'rubygems'
2
+ require 'rake'
3
+
4
+ begin
5
+ require 'jeweler'
6
+ Jeweler::Tasks.new do |gem|
7
+ gem.name = "mailcatcher"
8
+ gem.summary = %Q{Runs an SMTP server, catches and displays email in a web interface.}
9
+ gem.description = <<-EOD
10
+ MailCatcher runs a super simple SMTP server which catches any
11
+ message sent to it to display in a web interface. Run
12
+ mailcatcher, set your favourite app to deliver to
13
+ smtp://127.0.0.1:1025 instead of your default SMTP server,
14
+ then check out http://127.0.0.1:1080 to see the mail.
15
+ EOD
16
+ gem.email = "sj26@sj26.com"
17
+ gem.homepage = "http://github.com/sj26/mailcatcher"
18
+ gem.authors = ["Samuel Cochran"]
19
+
20
+ gem.add_dependency 'eventmachine'
21
+ gem.add_dependency 'mail'
22
+ gem.add_dependency 'i18n'
23
+ gem.add_dependency 'sqlite3-ruby'
24
+ gem.add_dependency 'thin'
25
+ gem.add_dependency 'skinny'
26
+ gem.add_dependency 'haml'
27
+ gem.add_dependency 'json'
28
+ end
29
+ Jeweler::GemcutterTasks.new
30
+ rescue LoadError
31
+ puts "Jeweler (or a dependency) not available. Install it with: gem install jeweler"
32
+ end
33
+
34
+ require 'rake/rdoctask'
35
+ Rake::RDocTask.new do |rdoc|
36
+ version = File.exist?('VERSION') ? File.read('VERSION') : ""
37
+
38
+ rdoc.rdoc_dir = 'rdoc'
39
+ rdoc.title = "MailCatcher #{version}"
40
+ rdoc.rdoc_files.include('README*')
41
+ rdoc.rdoc_files.include('lib/*.rb')
42
+ rdoc.rdoc_files.include('lib/**/*.rb')
43
+ end
data/VERSION ADDED
@@ -0,0 +1 @@
1
+ 0.2.0
data/lib/mail_catcher.rb CHANGED
@@ -1,215 +1,12 @@
1
- require 'rubygems'
2
1
  require 'eventmachine'
3
- require 'json'
4
- require 'ostruct'
5
- require 'mail'
6
2
  require 'thin'
7
- require 'sqlite3'
8
- require 'sinatra/base'
9
- require 'sunshowers'
10
-
11
- # Monkey-patch Sinatra to use Sunshowers
12
- class Sinatra::Request < Rack::Request
13
- include Sunshowers::WebSocket
14
- end
15
3
 
16
4
  module MailCatcher
17
- def self.db
18
- @@__db ||= begin
19
- SQLite3::Database.new(':memory:', :results_as_hash => true, :type_translation => true).tap do |db|
20
- begin
21
- db.execute(<<-SQL)
22
- CREATE TABLE mail (
23
- id INTEGER PRIMARY KEY ASC,
24
- sender TEXT,
25
- recipients TEXT,
26
- subject TEXT,
27
- source BLOB,
28
- size TEXT,
29
- created_at DATETIME DEFAULT CURRENT_DATETIME
30
- )
31
- SQL
32
- db.execute(<<-SQL)
33
- CREATE TABLE part (
34
- id INTEGER PRIMARY KEY ASC,
35
- mail_id INTEGER NOT NULL,
36
- cid TEXT,
37
- type TEXT,
38
- is_attachment INTEGER,
39
- filename TEXT,
40
- body BLOB,
41
- size INTEGER,
42
- created_at DATETIME DEFAULT CURRENT_DATETIME
43
- )
44
- SQL
45
- rescue SQLite3::SQLException
46
- end
47
- end
48
- end
49
- end
50
-
51
- def self.subscribers
52
- @@subscribers ||= []
53
- end
5
+ autoload :Events, 'mail_catcher/events'
6
+ autoload :Mail, 'mail_catcher/mail'
7
+ autoload :Smtp, 'mail_catcher/smtp'
8
+ autoload :Web, 'mail_catcher/web'
54
9
 
55
- class SmtpServer < EventMachine::Protocols::SmtpServer
56
- def insert_message
57
- @@insert_message ||= MailCatcher.db.prepare("INSERT INTO mail (sender, recipients, subject, source, size, created_at) VALUES (?, ?, ?, ?, ?, datetime('now'))")
58
- end
59
-
60
- def insert_part
61
- @@insert_part ||= MailCatcher.db.prepare("INSERT INTO part (mail_id, cid, type, is_attachment, filename, body, size, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, datetime('now'))")
62
- end
63
-
64
- def current_message
65
- @current_message ||= OpenStruct.new
66
- end
67
-
68
- def receive_reset
69
- @current_message = nil
70
- true
71
- end
72
-
73
- def receive_sender(sender)
74
- current_message.sender = sender
75
- true
76
- end
77
-
78
- def receive_recipient(recipient)
79
- current_message.recipients ||= []
80
- current_message.recipients << recipient
81
- true
82
- end
83
-
84
- def receive_data_chunk(lines)
85
- current_message.source ||= ""
86
- current_message.source += lines.join("\n")
87
- true
88
- end
89
-
90
- def receive_message
91
- MailCatcher.db.transaction do
92
- mail = Mail.new(current_message.source)
93
- result = insert_message.execute(current_message.sender, current_message.recipients.inspect, mail.subject, current_message.source, current_message.source.length)
94
- mail_id = MailCatcher.db.last_insert_row_id
95
- if mail.multipart?
96
- mail.all_parts.each do |part|
97
- body = part.body.to_s
98
- insert_part.execute(mail_id, part.cid, part.mime_type, part.attachment? ? 1 : 0, part.filename, body, body.length)
99
- end
100
- else
101
- body = mail.body.to_s
102
- insert_part.execute(mail_id, nil, mail.mime_type, 0, mail.filename, body, body.length)
103
- end
104
- puts "==> SMTP: Received message '#{mail.subject}' from '#{current_message.sender}'"
105
- true
106
- end
107
- rescue
108
- puts "*** Error receiving message: #{current_message.inspect}"
109
- puts " Exception: #{$!}"
110
- puts " Backtrace:"
111
- $!.backtrace.each do |line|
112
- puts " #{line}"
113
- end
114
- puts " Please submit this as an issue at http://github.com/sj26/mailcatcher/issues"
115
- ensure
116
- @current_message = nil
117
- end
118
- end
119
-
120
- class WebApp < Sinatra::Base
121
- set :views, File.expand_path(File.join(File.dirname(__FILE__), '..', 'views'))
122
- set :haml, {:format => :html5 }
123
-
124
- get '/' do
125
- haml :index
126
- end
127
-
128
- get '/mail' do
129
- if latest = MailCatcher.db.query('SELECT created_at FROM mail ORDER BY created_at DESC LIMIT 1').next
130
- last_modified latest["created_at"]
131
- end
132
- MailCatcher.db.query('SELECT id, sender, recipients, subject, size, created_at FROM mail ORDER BY created_at DESC').to_a.to_json
133
- end
134
-
135
- get '/mail/:id.json' do
136
- mail_id = params[:id].to_i
137
- message = MailCatcher.db.query('SELECT * FROM mail WHERE id = ? LIMIT 1', mail_id).to_a.first
138
- if message
139
- last_modified message["created_at"]
140
- message["formats"] = ['eml']
141
- message["formats"] << 'html' if MailCatcher.db.query('SELECT id FROM part WHERE mail_id = ? AND type = "text/html" LIMIT 1', mail_id).next
142
- message["formats"] << 'txt' if MailCatcher.db.query('SELECT id FROM part WHERE mail_id = ? AND type = "text/plain" LIMIT 1', mail_id).next
143
- message["attachments"] = MailCatcher.db.query('SELECT cid, type, filename, size FROM part WHERE mail_id = ? AND is_attachment = 1 ORDER BY filename ASC', mail_id).to_a.map do |attachment|
144
- attachment.merge({"href" => "/mail/#{escape(params[:id])}/#{escape(attachment['cid'])}"})
145
- end
146
- message.to_json
147
- else
148
- not_found
149
- end
150
- end
151
-
152
- get '/mail/:id.html' do
153
- mail_id = params[:id].to_i
154
- part = MailCatcher.db.query('SELECT body, created_at FROM part WHERE mail_id = ? AND type = "text/html" LIMIT 1', mail_id).to_a.first
155
- if part
156
- content_type 'text/html'
157
- last_modified part["created_at"]
158
- part["body"].gsub(/cid:([^'"> ]+)/, "#{mail_id}/\\1")
159
- else
160
- not_found
161
- end
162
- end
163
-
164
- get '/mail/:id.txt' do
165
- part = MailCatcher.db.query('SELECT body, created_at FROM part WHERE mail_id = ? AND type = "text/plain" LIMIT 1', params[:id].to_i).to_a.first
166
- if part
167
- content_type 'text/plain'
168
- last_modified part["created_at"]
169
- part["body"]
170
- else
171
- not_found
172
- end
173
- end
174
-
175
- get '/mail/:id.eml' do
176
- content_type 'text/plain'
177
- message = MailCatcher.db.query('SELECT source, created_at FROM mail WHERE id = ? ORDER BY created_at DESC LIMIT 1', params[:id].to_i).to_a.first
178
- if message
179
- last_modified message["created_at"]
180
- message["source"]
181
- else
182
- not_found
183
- end
184
- end
185
-
186
- get '/mail/:id/:cid' do
187
- result = MailCatcher.db.query('SELECT * FROM part WHERE mail_id = ?', params[:id].to_i)
188
- part = result.find { |part| part["cid"] == params[:cid] }
189
- if part
190
- content_type part["type"]
191
- attachment part["filename"] if part["is_attachment"] == 1
192
- last_modified part["created_at"]
193
- body part["body"].to_s
194
- else
195
- not_found
196
- end
197
- end
198
-
199
- get '/mail/subscribe' do
200
- return head 400 unless request.ws?
201
-
202
- request.ws_handshake!
203
- request.ws_io.each do |message|
204
- ws_quit! if message == "goodbye"
205
- end
206
- end
207
-
208
- not_found do
209
- "<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>"
210
- end
211
- end
212
-
213
10
  def self.run(options = {})
214
11
  options[:smtp_ip] ||= '127.0.0.1'
215
12
  options[:smtp_port] ||= 1025
@@ -221,9 +18,9 @@ module MailCatcher
221
18
  puts "==> http://#{options[:http_ip]}:#{options[:http_port]}"
222
19
 
223
20
  Thin::Logging.silent = true
224
- EM.run do
225
- EM.start_server options[:smtp_ip], options[:smtp_port], SmtpServer
226
- Thin::Server.start WebApp, options[:http_ip], options[:http_port]
21
+ EventMachine.run do
22
+ EventMachine.start_server options[:smtp_ip], options[:smtp_port], Smtp
23
+ Thin::Server.start options[:http_ip], options[:http_port], Web
227
24
  end
228
25
  end
229
26
  end
@@ -0,0 +1,7 @@
1
+ require 'eventmachine'
2
+
3
+ module MailCatcher
4
+ module Events
5
+ MessageAdded = EventMachine::Channel.new
6
+ end
7
+ end
@@ -0,0 +1,119 @@
1
+ require 'mail'
2
+ require 'sqlite3'
3
+ require 'eventmachine'
4
+
5
+ module MailCatcher::Mail
6
+ class << self
7
+ def db
8
+ @@__db ||= begin
9
+ SQLite3::Database.new(':memory:', :results_as_hash => true, :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
+ created_at DATETIME DEFAULT CURRENT_DATETIME
19
+ )
20
+ SQL
21
+ db.execute(<<-SQL)
22
+ CREATE TABLE message_part (
23
+ id INTEGER PRIMARY KEY ASC,
24
+ message_id INTEGER NOT NULL,
25
+ cid TEXT,
26
+ type TEXT,
27
+ is_attachment INTEGER,
28
+ filename TEXT,
29
+ charset TEXT,
30
+ body BLOB,
31
+ size INTEGER,
32
+ created_at DATETIME DEFAULT CURRENT_DATETIME
33
+ )
34
+ SQL
35
+ end
36
+ end
37
+ end
38
+
39
+ def add_message(message)
40
+ @@add_message_query ||= db.prepare("INSERT INTO message (sender, recipients, subject, source, size, created_at) VALUES (?, ?, ?, ?, ?, datetime('now'))")
41
+
42
+ mail = Mail.new(message[:source])
43
+ result = @@add_message_query.execute(message[:sender], message[:recipients].inspect, mail.subject, message[:source], message[:source].length)
44
+ message_id = db.last_insert_row_id
45
+ (mail.all_parts || [mail]).each do |part|
46
+ body = part.body.to_s
47
+ add_message_part(message_id, part.cid, part.mime_type || 'text/plain', part.attachment? ? 1 : 0, part.filename, part.charset, body, body.length)
48
+ end
49
+
50
+ EventMachine.next_tick do
51
+ message = MailCatcher::Mail.message message_id
52
+ MailCatcher::Events::MessageAdded.push message
53
+ end
54
+ end
55
+
56
+ def add_message_part(*args)
57
+ @@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'))"
58
+ @@add_message_part_query.execute(*args)
59
+ end
60
+
61
+ def latest_created_at
62
+ @@latest_created_at_query ||= db.prepare "SELECT created_at FROM message ORDER BY created_at DESC LIMIT 1"
63
+ @@latest_created_at_query.execute.next
64
+ end
65
+
66
+ def messages
67
+ @@messages_query ||= db.prepare "SELECT id, sender, recipients, subject, size, created_at FROM message ORDER BY created_at DESC"
68
+ @@messages_query.execute.to_a
69
+ end
70
+
71
+ def message(id)
72
+ @@message_query ||= db.prepare "SELECT * FROM message WHERE id = ? LIMIT 1"
73
+ @@message_query.execute(id).next
74
+ end
75
+
76
+ def message_has_html?(id)
77
+ @@message_has_html_query ||= db.prepare "SELECT 1 FROM message_part WHERE message_id = ? AND is_attachment = 0 AND type = 'text/html' LIMIT 1"
78
+ !!@@message_has_html_query.execute(id).next
79
+ end
80
+
81
+ def message_has_plain?(id)
82
+ @@message_has_html_query ||= db.prepare "SELECT 1 FROM message_part WHERE message_id = ? AND is_attachment = 0 AND type = 'text/plain' LIMIT 1"
83
+ !!@@message_has_html_query.execute(id).next
84
+ end
85
+
86
+ def message_parts(id)
87
+ @@message_parts_query ||= db.prepare "SELECT cid, type, filename, size FROM message_part WHERE message_id = ? ORDER BY filename ASC"
88
+ @@message_parts_query.execute(id).to_a
89
+ end
90
+
91
+ def message_attachments(id)
92
+ @@message_parts_query ||= db.prepare "SELECT cid, type, filename, size FROM message_part WHERE message_id = ? AND is_attachment = 1 ORDER BY filename ASC"
93
+ @@message_parts_query.execute(id).to_a
94
+ end
95
+
96
+ def message_part(message_id, part_id)
97
+ @@message_part_query ||= db.prepare "SELECT * FROM message_part WHERE message_id = ? AND id = ? LIMIT 1"
98
+ @@message_part_query.execute(message_id, part_id).next
99
+ end
100
+
101
+ def message_part_type(message_id, part_type)
102
+ @@message_part_type_query ||= db.prepare "SELECT * FROM message_part WHERE message_id = ? AND type = ? AND is_attachment = 0 LIMIT 1"
103
+ @@message_part_type_query.execute(message_id, part_type).next
104
+ end
105
+
106
+ def message_part_html(message_id)
107
+ message_part_type message_id, "text/html"
108
+ end
109
+
110
+ def message_part_plain(message_id)
111
+ message_part_type message_id, "text/plain"
112
+ end
113
+
114
+ def message_part_cid(message_id, cid)
115
+ @@message_part_cid_query ||= db.prepare 'SELECT * FROM message_part WHERE message_id = ?'
116
+ @@message_part_cid_query.execute(message_id).find { |part| part["cid"] == cid }
117
+ end
118
+ end
119
+ end