メールをどこにも送らず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