First cut of mailcatcher

This commit is contained in:
Samuel Cochran 2010-10-25 08:51:17 +08:00
commit 0fb61d040b
3 changed files with 358 additions and 0 deletions

10
bin/mailcatcher Executable file
View File

@ -0,0 +1,10 @@
#!/usr/bin/env ruby
$: << File.expand_path(File.join(File.dirname(__FILE__), '../lib'))
require 'mail_catcher'
puts 'Starting mail catcher'
puts '==> smtp://0.0.0.0:1025'
puts '==> http://0.0.0.0:1080'
MailCatcher.run

209
lib/mail_catcher.rb Normal file
View File

@ -0,0 +1,209 @@
require 'rubygems'
require 'eventmachine'
require 'json'
require 'ostruct'
require 'mail'
require 'thin'
require 'sqlite3'
require 'sinatra/base'
require 'sunshowers'
# Monkey-patch Sinatra to use Sunshowers
class Sinatra::Request < Rack::Request
include Sunshowers::WebSocket
end
module MailCatcher
def self.db
@@__db ||= begin
SQLite3::Database.new(':memory:', :results_as_hash => true, :type_translation => true).tap do |db|
begin
db.execute(<<-SQL)
CREATE TABLE mail (
id INTEGER PRIMARY KEY ASC,
sender TEXT,
recipients TEXT,
subject TEXT,
source BLOB,
size TEXT,
created_at DATETIME DEFAULT CURRENT_DATETIME
)
SQL
db.execute(<<-SQL)
CREATE TABLE part (
id INTEGER PRIMARY KEY ASC,
mail_id INTEGER NOT NULL,
cid TEXT,
type TEXT,
is_attachment INTEGER,
filename TEXT,
body BLOB,
size INTEGER,
created_at DATETIME DEFAULT CURRENT_DATETIME
)
SQL
rescue SQLite3::SQLException
end
end
end
end
def self.subscribers
@@subscribers ||= []
end
class SmtpServer < EventMachine::Protocols::SmtpServer
def insert_message
@@insert_message ||= MailCatcher.db.prepare("INSERT INTO mail (sender, recipients, subject, source, size, created_at) VALUES (?, ?, ?, ?, ?, datetime('now'))")
end
def insert_part
@@insert_part ||= MailCatcher.db.prepare("INSERT INTO part (mail_id, cid, type, is_attachment, filename, body, size, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, datetime('now'))")
end
def current_message
@current_message ||= OpenStruct.new
end
def receive_reset
@current_message = nil
true
end
def receive_sender(sender)
current_message.sender = sender
true
end
def receive_recipient(recipient)
current_message.recipients ||= []
current_message.recipients << recipient
true
end
def receive_data_chunk(lines)
current_message.source ||= ""
current_message.source += lines.join("\n")
true
end
def receive_message
mail = Mail.new(current_message.source)
result = insert_message.execute(current_message.sender, current_message.recipients.inspect, mail.subject, current_message.source, current_message.source.length)
mail_id = MailCatcher.db.last_insert_row_id
if mail.multipart?
mail.all_parts.each do |part|
body = part.body.to_s
insert_part.execute(mail_id, part.cid, part.mime_type, part.attachment? ? 1 : 0, part.filename, body, body.length)
end
else
body = mail.body.to_s
insert_part.execute(mail_id, nil, mail.mime_type, 0, mail.filename, body, body.length)
end
puts "==> SMTP: Received message '#{mail.subject}' from '#{current_message.sender}'"
@current_message = nil
true
end
end
class WebApp < Sinatra::Base
set :views, File.expand_path(File.join(File.dirname(__FILE__), '..', 'views'))
set :haml, {:format => :html5 }
get '/' do
haml :index
end
get '/mail' do
if latest = MailCatcher.db.query('SELECT created_at FROM mail ORDER BY created_at DESC LIMIT 1').next
last_modified latest["created_at"]
end
MailCatcher.db.query('SELECT id, sender, recipients, subject, size, created_at FROM mail ORDER BY created_at DESC').to_a.to_json
end
get '/mail/:id.json' do
mail_id = params[:id].to_i
message = MailCatcher.db.query('SELECT * FROM mail WHERE id = ? LIMIT 1', mail_id).to_a.first
if message
last_modified message["created_at"]
message["formats"] = ['eml']
message["formats"] << 'html' if MailCatcher.db.query('SELECT id FROM part WHERE mail_id = ? AND type = "text/html" LIMIT 1', mail_id).next
message["formats"] << 'txt' if MailCatcher.db.query('SELECT id FROM part WHERE mail_id = ? AND type = "text/plain" LIMIT 1', mail_id).next
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|
attachment.merge({"href" => "/mail/#{escape(params[:id])}/#{escape(attachment['cid'])}"})
end
message.to_json
else
not_found
end
end
get '/mail/:id.html' do
mail_id = params[:id].to_i
part = MailCatcher.db.query('SELECT body, created_at FROM part WHERE mail_id = ? AND type = "text/html" LIMIT 1', mail_id).to_a.first
if part
content_type 'text/html'
last_modified part["created_at"]
part["body"].gsub(/cid:([^'"> ]+)/, "#{mail_id}/\\1")
else
not_found
end
end
get '/mail/:id.txt' do
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
if part
content_type 'text/plain'
last_modified part["created_at"]
part["body"]
else
not_found
end
end
get '/mail/:id.eml' do
content_type 'text/plain'
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
if message
last_modified message["created_at"]
message["source"]
else
not_found
end
end
get '/mail/:id/:cid' do
result = MailCatcher.db.query('SELECT * FROM part WHERE mail_id = ?', params[:id].to_i)
part = result.find { |part| part["cid"] == params[:cid] }
if part
content_type part["type"]
attachment part["filename"] if part["is_attachment"] == 1
last_modified part["created_at"]
body part["body"].to_s
else
not_found
end
end
get '/mail/subscribe' do
return head 400 unless request.ws?
request.ws_handshake!
request.ws_io.each do |message|
ws_quit! if message == "goodbye"
end
end
not_found do
"<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>"
end
end
def self.run
Thin::Logging.silent = true
EM::run do
EM::start_server '127.0.0.1', 1025, SmtpServer
Thin::Server.start WebApp, '127.0.0.1', 1080
end
end
end

139
views/index.haml Normal file
View File

@ -0,0 +1,139 @@
!!!
%html
%head
%title MailCatcher
%script{:src => "http://ipv.local/javascripts/jquery-1.4.2.min.js"}
:javascript
var MailCatcher = {
refresh: function() {
console.log('Refreshing mail');
$.getJSON('/mail', function(mail) {
$.each(mail, function(i, message) {
var row = $('<tr />').attr('data-message-id', message.id.toString());
$.each(['sender', 'recipients', 'subject', 'created_at'], function (i, property) {
row.append($('<td />').text(message[property]));
});
$('#mail tbody').append(row);
});
});
},
load: function(id) {
var id = id || $('#mail tr.selected').attr('data-message-id');
if (id !== null) {
console.log('Loading message', id);
$('#mail tbody tr:not([data-message-id="'+id+'"])').removeClass('selected');
$('#mail tbody tr[data-message-id="'+id+'"]').addClass('selected');
$.getJSON('/mail/' + id + '.json', function(message) {
$('#message .received span').text(message.created_at);
$('#message .from span').text(message.sender);
$('#message .to span').text(message.recipients);
$('#message .subject span').text(message.subject);
$('#message .formats ul li').each(function(i, el) {
var $el = $(el),
format = $el.attr('data-message-format');
if ($.inArray(format, message.formats) >= 0) {
$el.show();
} else {
$el.hide();
}
})
if ($("#message .formats ul li.selected:not(:visible)")) {
$("#message .formats ul li.selected").removeClass("selected");
$("#message .formats ul li:visible:first").addClass("selected");
}
if (message.attachments.length > 0) {
console.log(message.attachments);
$('#message .attachments ul').empty();
$.each(message.attachments, function (i, attachment) {
$('#message .attachments ul').append($('<li>').append($('<a>').attr('href', attachment['href']).addClass(attachment['type'].split('/', 1)[0]).addClass(attachment['type'].replace('/', '-')).text(attachment['filename'])));
});
$('#message .attachments').show();
} else {
$('#message .attachments').hide();
}
MailCatcher.loadBody();
});
}
},
loadBody: function(id, format) {
var id = id || $('#mail tr.selected').attr('data-message-id');
var format = format || $('#message .formats ul li.selected').first().attr('data-message-format') || 'html';
$('#message .formats ul li[data-message-format="'+format+'"]').addClass('selected');
$('#message .formats ul li:not([data-message-format="'+format+'"])').removeClass('selected');
if (id != undefined && id !== null) {
console.log('Loading message', id, 'in format', format);
$('#message iframe').attr('src', '/mail/' + id + '.' + format);
}
}
};
$('#mail tr').live('click', function() {
MailCatcher.load($(this).attr('data-message-id'));
});
$('#message .formats ul li').live('click', function() {
MailCatcher.loadBody($('#mail tr.selected').attr('data-message-id'), $(this).attr('data-message-format'));
});
$(MailCatcher.refresh);
:css
html, body, div, span, applet, object, iframe, h1, h2, h3, h4, h5, h6, p, blockquote, pre, a, abbr, acronym, address, big, cite, code, del, dfn, em, font, img, ins, kbd, q, s, samp, small, strike, strong, sub, sup, tt, var, dl, dt, dd, ol, ul, li, fieldset, form, label, legend, table, caption, tbody, tfoot, thead, tr, th, td { margin:0px; padding:0px; border:0px; outline:0px; font-weight:inherit; font-style:inherit; font-size:100%; font-family:inherit; vertical-align:baseline; }
body { line-height:1; color:black; background:white; font-size:12px; font-family:Helvetica, Arial, sans-serif; }
ol, ul { list-style:none; }
table { border-collapse:separate; border-spacing:0px; }
caption, th, td { text-align:left; font-weight:normal; }
#mail { height: 10em; overflow: scroll; }
#mail table { width: 100%; }
#mail table thead tr { background: #ccc; }
#mail table thead tr th { padding: .25em; font-weight: bold; }
#mail table tbody tr:nth-child(even) { background: #eee; }
#mail table tbody tr.selected { background: Highlight; color: HighlightText; }
#mail table tbody tr td { padding: .25em; }
#message .metadata { padding: 1em; background: #ccc; }
#message .metadata div { padding: .25em;; }
#message .metadata div label { display: inline-block; width: 8em; text-align: right; font-weight: bold; }
#message .metadata .attachments { display: none; }
#message .metadata .attachments ul { display: inline; }
#message .metadata .attachments ul li { display: inline-block; }
#message .formats ul { background: #ccc; border-bottom: 1px solid #666; padding: 0 .5em; }
#message .formats ul li { display: inline-block; padding: .5em; border: solid #666; border-width: 1px 1px 0 1px; }
#message .formats ul li.selected { background: white; height: 13px; margin-bottom: -1px; }
#message iframe { width: 100%; height: 42em; }
%body
#mail
%table
%thead
%th From
%th To
%th Subject
%th Received
%tbody
#message
.metadata
.received
%label Received
%span
.from
%label From
%span
.to
%label To
%span
.subject
%label Subject
%span
.attachments
%label Attachments
%ul
.formats
%ul
%li.selected{'data-message-format' => 'html'} HTML
%li{'data-message-format' => 'txt'} Plain Text
%li{'data-message-format' => 'eml'} Source
%iframe