mailcatcher 0.1.6 → 0.2.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.
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