(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を作成します。

あとは、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

|\r|

)/, "<br />

") 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?

/, '') 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

" 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