メールをどこにも送らずHTMLで保存するSMTPサーバ mocksmtpd.rb
(2014/6/3 追記) MailCatcher がおすすめです。
(2008/11/4追記) gem版も作ってみました。
id:muscovyduckさんの(素晴らしい)記事を参考に、ちょっとだけ手を加えて開発用のSMTPサーバ mocksmtpd.rb を作成しました。メールを外に出さずにHTMLで保存する単純なSMTPサーバです。
これを使うと、Seleniumでメールのテストが簡単にできるようになります。ユーザ登録時にURLをメールで送信して本人確認とか。間にメールが挟まってもテストがつながります。


使い方
# コンソールで実行 mocksmtpd.rb # デーモンとして実行 mocksmtpd.rb -d # デーモンを停止 mocksmtpd.rb stop
他にオプションはありません。設定を変えたい時はスクリプトの最初の辺りを見てください、、。
HTMLはスクリプトと同じディレクトリに置かれたinboxという名前のディレクトリに保存します。
デーモンとして実行する場合、logという名前のディレクトリが必要です。ログとpidファイルを保存します。
mocksmtpd.rb smtpserver.rb log/ inbox/
25番ポートを開くためにrootで実行するけど、HTMLは一般ユーザで作成したいような場合、euidとegidという変数にuid/gidを設定してください。実効ユーザIDを変更します。たとえばソースの以下のコメントを外すと、mocksmtpd.rbと同じユーザでHTMLを作成します。
# euid = File::Stat.new(__FILE__).uid # egid = File::Stat.new(__FILE__).gid
あとは、iptablesとかxinetdとかを使って25番ポートをリダイレクトする方法もあるみたいです。よく知りません。(どちらもこないだ人から教わった)
以前書いたApache Jamesの記事もよかったら見てみてください。もう使わないと思うけど。モチベーションとかが書いてあります。
ソース
mocksmtpd.rb
#! /usr/bin/env ruby
DIR = File.expand_path(File.dirname(__FILE__))
$:.unshift(DIR)
require 'smtpserver'
require 'erb'
require 'nkf'
include ERB::Util
logfile = "#{DIR}/log/mocksmtpd.log"
pidfile = "#{DIR}/log/mocksmtpd.pid"
inbox = "#{DIR}/inbox"
euid,egid,umask = nil,nil,nil
# euid = File::Stat.new(__FILE__).uid
# egid = File::Stat.new(__FILE__).gid
# umask = 2
config = {
:Port => 25,
:ServerName => 'mocksmtpd',
:RequestTimeout => 120,
:LineLengthLimit => 1024,
}
if ARGV.include? "-d"
daemon = true
end
if ARGV.include? "stop"
pid = File.read(pidfile)
system "kill -TERM #{pid}"
exit
end
logger = daemon ? WEBrick::Log.new(logfile, WEBrick::BasicLog::INFO) : WEBrick::Log.new
start_cb = Proc.new do
File.umask(umask) unless umask.nil?
Process.egid = egid unless egid.nil?
Process.euid = euid unless euid.nil?
if daemon
if File.exist?(pidfile)
pid = File.read(pidfile)
logger.warn("pid file already exists: #{pid}")
exit
end
open(pidfile, "w") do |io|
io << Process.pid
end
end
end
stop_cb = Proc.new do
File.delete(pidfile) if daemon
end
config[:ServerType] = daemon ? WEBrick::Daemon : nil
config[:Logger] = logger
config[:StartCallback] = start_cb
config[:StopCallback] = stop_cb
eval DATA.read
def save_entry(mail)
open(mail[:path], "w") do |io|
io << ERB.new(ENTRY_ERB, nil, "%-").result(binding)
end
end
def save_index(mail, path)
unless File.exist?(path)
open(path, "w") do |io|
io << INDEX_SRC
end
end
htmlsrc = File.read(path)
add = ERB.new(INDEX_ITEM_ERB, nil, "%-").result(binding)
htmlsrc.sub!(/<!-- ADD -->/, add)
open(path, "w") do |io|
io << htmlsrc
end
end
config[:DataHook] = Proc.new do |src, sender, recipients|
logger.info "mail recieved from #{sender}"
src = NKF.nkf("-wm", src)
subject = src.match(/^Subject:\s*(.+)/i).to_a[1].to_s.strip
date = src.match(/^Date:\s*(.+)/i).to_a[1].to_s.strip
src = ERB::Util.h(src)
src = src.gsub(%r{https?://[-_.!~*\'()a-zA-Z0-9;\/?:\@&=+\$,%#]+},'<a href="\0">\0</a>')
src = src.gsub(/(?:\r\n|\r|\n)/, "<br />\n")
if date.empty?
date = Time.now
else
date = Time.parse(date)
end
mail = {
:source => src,
:sender => sender,
:recipients => recipients,
:subject => subject,
:date => date,
}
format = "%Y%m%d%H%M%S"
fname = date.strftime(format) + ".html"
while File.exist?(inbox + "/" + fname)
date += 1
fname = date.strftime(format) + ".html"
end
mail[:file] = fname
mail[:path] = inbox + "/" + fname
save_entry(mail)
save_index(mail, inbox + "/index.html")
end
server = SMTPServer.new(config)
[:INT, :TERM].each do |signal|
Signal.trap(signal) { server.shutdown }
end
server.start
__END__
ENTRY_ERB = <<'EOT'
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="ja-JP" lang="ja-JP">
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<link rel="index" href="./index.html" />
<title><%=h mail[:subject] %> (<%= mail[:date].to_s %>)</title>
</head>
<body style="background:#eee">
<h1 id="subject"><%=h mail[:subject] %></h1>
<div><p id="date" style="font-size:0.8em;"><%= mail[:date].to_s %></div>
<div id="source" style="border: solid 1px #666; background:white; padding:2em;">
<p><%= mail[:source] %></p>
</div>
</body>
</html>
EOT
INDEX_SRC = <<'EOT'
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="ja-JP" lang="ja-JP">
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<link rel="index" href="./index.html" />
<title>Inbox</title>
<style type="text/css">
body {
background:#eee;
}
table {
border: 1px #999 solid;
border-collapse: collapse;
}
th, td {
border: 1px #999 solid;
padding: 6px 12px;
}
th {
background: #ccc;
}
td {
background: white;
}
</style>
</head>
<body>
<h1>Inbox</h1>
<table>
<thead>
<tr>
<th>Date</th>
<th>Subject</th>
<th>From</th>
<th>To</th>
</tr>
</thead>
<tbody>
<!-- ADD -->
</tbody>
</table>
</body>
</html>
EOT
INDEX_ITEM_ERB = <<'EOT'
<!-- ADD -->
<tr>
<td><%= mail[:date].strftime("%Y-%m-%d %H:%M:%S") %></td>
<td><a href="<%=h mail[:file] %>"><%=h mail[:subject] %></a></td>
<td><%=h mail[:sender] %></td>
<td><%=h mail[:recipients].to_a.join(",") %></td>
</tr>
EOT
smtpserver.rb
require 'webrick'
require 'tempfile'
module GetsSafe
def gets_safe(rs = nil, timeout = @timeout, maxlength = @maxlength)
rs = $/ unless rs
f = self.kind_of?(IO) ? self : STDIN
@gets_safe_buf = '' unless @gets_safe_buf
until @gets_safe_buf.include? rs do
if maxlength and @gets_safe_buf.length > maxlength then
raise Errno::E2BIG, 'too long'
end
if IO.select([f], nil, nil, timeout) == nil then
raise Errno::ETIMEDOUT, 'timeout exceeded'
end
begin
@gets_safe_buf << f.sysread(4096)
rescue EOFError, Errno::ECONNRESET
return @gets_safe_buf.empty? ? nil : @gets_safe_buf.slice!(0..-1)
end
end
p = @gets_safe_buf.index rs
if maxlength and p > maxlength then
raise Errno::E2BIG, 'too long'
end
return @gets_safe_buf.slice!(0, p+rs.length)
end
attr_accessor :timeout, :maxlength
end
class SMTPD
class Error < StandardError; end
def initialize(sock, domain)
@sock = sock
@domain = domain
@error_interval = 5
class << @sock
include GetsSafe
end
@helo_hook = nil
@mail_hook = nil
@rcpt_hook = nil
@data_hook = nil
@data_each_line = nil
@rset_hook = nil
@noop_hook = nil
@quit_hook = nil
end
attr_writer :helo_hook, :mail_hook, :rcpt_hook, :data_hook,
:data_each_line, :rset_hook, :noop_hook, :quit_hook
def start
@helo_name = nil
@sender = nil
@recipients = []
catch(:close) do
puts_safe "220 #{@domain} service ready"
while comm = @sock.gets_safe do
catch :next_comm do
comm.sub!(/\r?\n/, '')
comm, arg = comm.split(/\s+/,2)
break if comm == nil
case comm.upcase
when 'EHLO' then comm_helo arg
when 'HELO' then comm_helo arg
when 'MAIL' then comm_mail arg
when 'RCPT' then comm_rcpt arg
when 'DATA' then comm_data arg
when 'RSET' then comm_rset arg
when 'NOOP' then comm_noop arg
when 'QUIT' then comm_quit arg
else
error '502 Error: command not implemented'
end
end
end
end
end
def line_length_limit=(n)
@sock.maxlength = n
end
def input_timeout=(n)
@sock.timeout = n
end
attr_reader :line_length_limit, :input_timeout
attr_accessor :error_interval
attr_accessor :use_file, :max_size
private
def comm_helo(arg)
if arg == nil or arg.split.size != 1 then
error '501 Syntax: HELO hostname'
end
@helo_hook.call(arg) if @helo_hook
@helo_name = arg
reply "250 #{@domain}"
end
def comm_mail(arg)
if @sender != nil then
error '503 Error: nested MAIL command'
end
if arg !~ /^FROM:/i then
error '501 Syntax: MAIL FROM: <address>'
end
sender = parse_addr $'
if sender == nil then
error '501 Syntax: MAIL FROM: <address>'
end
@mail_hook.call(sender) if @mail_hook
@sender = sender
reply '250 Ok'
end
def comm_rcpt(arg)
if @sender == nil then
error '503 Error: need MAIL command'
end
if arg !~ /^TO:/i then
error '501 Syntax: RCPT TO: <address>'
end
rcpt = parse_addr $'
if rcpt == nil then
error '501 Syntax: RCPT TO: <address>'
end
@rcpt_hook.call(rcpt) if @rcpt_hook
@recipients << rcpt
reply '250 Ok'
end
def comm_data(arg)
if @recipients.size == 0 then
error '503 Error: need RCPT command'
end
if arg != nil then
error '501 Syntax: DATA'
end
reply '354 End data with <CR><LF>.<CR><LF>'
if @data_hook
tmpf = @use_file ? Tempfile.new('smtpd') : ''
end
size = 0
loop do
l = @sock.gets_safe
if l == nil then
raise SMTPD::Error, 'unexpected EOF'
end
if l.chomp == '.' then break end
if l[0] == ?. then
l[0,1] = ''
end
size += l.size
if @max_size and @max_size < size then
error '552 Error: message too large'
end
@data_each_line.call(l) if @data_each_line
tmpf << l if @data_hook
end
if @data_hook then
if @use_file then
tmpf.pos = 0
@data_hook.call(tmpf, @sender, @recipients)
tmpf.close(true)
else
@data_hook.call(tmpf, @sender, @recipients)
end
end
reply '250 Ok'
@sender = nil
@recipients = []
end
def comm_rset(arg)
if arg != nil then
error '501 Syntax: RSET'
end
@rset_hook.call(@sender, @recipients) if @rset_hook
reply '250 Ok'
@sender = nil
@recipients = []
end
def comm_noop(arg)
if arg != nil then
error '501 Syntax: NOOP'
end
@noop_hook.call(@sender, @recipients) if @noop_hook
reply '250 Ok'
end
def comm_quit(arg)
if arg != nil then
error '501 Syntax: QUIT'
end
@quit_hook.call(@sender, @recipients) if @quit_hook
reply '221 Bye'
throw :close
end
def parse_addr(str)
str = str.strip
if str == '' then
return nil
end
if str =~ /^<(.*)>$/ then
return $1.gsub(/\s+/, '')
end
if str =~ /\s/ then
return nil
end
str
end
def reply(msg)
puts_safe msg
end
def error(msg)
sleep @error_interval if @error_interval
puts_safe msg
throw :next_comm
end
def puts_safe(str)
begin
@sock.puts str + "\r\n"
rescue
raise SMTPD::Error, "cannot send to client: '#{str.gsub(/\s+/," ")}': #{$!.to_s}"
end
end
end
SMTPDError = SMTPD::Error
class SMTPServer < WEBrick::GenericServer
def run(sock)
server = SMTPD.new(sock, @config[:ServerName])
server.input_timeout = @config[:RequestTimeout]
server.line_length_limit = @config[:LineLengthLimit]
server.helo_hook = @config[:HeloHook]
server.mail_hook = @config[:MailHook]
server.rcpt_hook = @config[:RcptHook]
server.data_hook = @config[:DataHook]
server.data_each_line = @config[:DataEachLine]
server.rset_hook = @config[:RsetHook]
server.noop_hook = @config[:NoopHook]
server.quit_hook = @config[:QuitHook]
server.start
end
end