Ruby Net::SMTP

Ruby Net::SMTP

これは 富士通クラウドテクノロジーズ Advent Calendar 2020FUJITSU Advent Calendar 2020の 5日目の記事です。

会社のアドベントカレンダーですが、記事の内容は会社とは関係ありません。

nagano.rb #6 で発表したネタです。


SMTP

SMTP は Simple Mail Transfer Protocol の略でメールを送信するためのプロトコルです。

RFC の変遷:

メールメッセージの形式(Internet Message Format)の RFC もセットで発行されていて、SMTP の次の番号が割り当てられてます:

ポート番号は 25番で smtp という名前が割り当てられています。

テキストプロトコルなので、人も喋ることができます(DATA から . までの形式が RFC 5322)。

S: 220 smtp.example.com ESMTP Postfix</span>
C: EHLO client.example.net</span>
S: 250-smtp.example.com
   250-PIPELINING
   250-SIZE 102400000
   250-VRFY
   250-ETRN
   250-STARTTLS
   250-AUTH DIGEST-MD5 NTLM CRAM-MD5 PLAIN LOGIN
   250-ENHANCEDSTATUSCODES
   250-8BITMIME
   250-DSN
   250 SMTPUTF8
C: MAIL FROM:<sender@example.com>
S: 250 2.1.0 Ok
C: RCPT TO:<rcpt1@example.com>
S: 250 2.1.5 Ok
C: RCPT TO:<rcpt2@example.com>
S: 250 2.1.5 Ok
C: DATA
S: 354 End data with <CR><LF>.<CR><LF>
C: From: sender@example.com
   To: rcpt1@example.com
   Cc: rcpt2@example.com
   Subject: test
   
   message body
   .
S: 250 2.0.0 Ok: queued as F074F9FB0E
C: QUIT
S: 221 2.0.0 Bye

間違ってる人がわりといるようなのですが、MAIL FROM:< の間に空白は入りません。また、メールアドレスは < > で括る必要があります。 実際には空白があったり < > が無くてもエラーにしないサーバーが多いですが、たまにプロトコル違反としてエラーにするサーバーがあったりします。

メール送受信

昔は、メールサーバーは誰から送られた誰宛のメールでも受け取って正しい送り先に転送していたようです。

ですが、送信元が詐称されたり、迷惑メールの送信に利用されたりするようになり、

現在は、

  • 信頼できるクライアントはどこ宛でもOK
  • 認証が通ったらどこ宛でもOK
  • それ以外は自分宛であればOK

という設定がされるのがふつうだと思います。

ただし、これでも自サーバー宛に直接送りつけてくるやつには対処できません。

プロバイダー側の対策として、Outbound Port 25 Blocking (OP25B) というのが導入されました。 これはプロバイダーが外向けの25ポートをブロックすることで、プロバイダーが用意したメールサーバー経由でしか外部にメールを送れないようにするものです。 多くのプロバイダーが導入し、これによりプロバイダー配下のネットワークから外部のメールサーバーに直接接続することはできなくなりました。

そして受信と送信(中継)が分離されるようになりました。

受信(MX)

  • 自分宛のメールを受けつけるため
  • TCP 25番ポート(smtp)
  • DNS の MX レコードでサーバーを指定
  • 一般利用者からは接続されない
  • 怪しいクライアントは拒否 (設定次第)
    • 送信者ドメインのSPFに登録されているか
    • IPアドレスを逆引き&正引きして元のIPアドレスになるか
    • EHLO名がDNS上に存在しているか

送信(中継)

  • 外部に送信するため
  • TCP 587番ポート(submission)
  • TCP 465番ポート(smtps, submissions)
  • どこ宛でもOK
  • 信頼できるクライアントからしか受け付けない
    • 認証が通ったクライアント
    • ローカルネットワークのクライアント

SMTP認証

メール送信時に認証が必要な場合には SMTP の AUTH 命令を使用します。

C: AUTH PLAIN dXNlcm5hbWUAdXNlcm5hbWUAcGFzc3dvcmQ=
S: 235 2.7.0 Authentication successful

一見わけのわからない文字列になってますが、PLAIN はユーザー名とパスワードをBase64 しただけの平文です。

PLAIN 以外の認証方式、たとえば CRAM-MD5 等を使えば Challenge-Response 方式の暗号化もできますが、サーバー内に平文のパスワードを保持しておかないといけないのがイマイチです。

まあ、パスワードだけ暗号化したとしてもメール本文が平文なので盗み見される可能性はありますし。

通信暗号化(STARTTLS)

ということで通信経路を暗号化しましょう。

SMTP 接続後に STARTTLS 命令を発行するとそれ以降 TLS での暗号化通信になります。 なお、465番ポートは HTTPS と同じように接続時から TLS 通信です。

S: 220 smtp.example.com ESMTP Postfix
C: EHLO client.example.net
S: 250-smtp.example.com
   250-STARTTLS              ← EHLO の応答に STARTTLS が含まれてれば使用可
   ...
C: STARTTLS
S: 220 2.0.0 Ready to start TLS
--- ここから TLS 通信 ---
C: EHLO client.example.net
S: 250-smtp.example.com
   ...
C: AUTH PLAIN dXNlcm5hbWUAdXNlcm5hbWUAcGFzc3dvcmQ=
S: 235 2.7.0 Authentication successful

暗号化通信は手動ではできないので手で SMTP を叩きたい場合は openssl を使います。

% openssl s_client -connect smtp.example.com:587 -starttls smtp
(STARTTLS まで自動でやってくれる)
--- ここから手で入力したものが TLS 通信でサーバーに送られる ---
C: EHLO client.example.net
S: 250-smtp.example.com
   ...

TLS証明書の検証

openssl はデフォルトでは証明書の検証をしないので、オレオレ証明書とか期限切れ証明書もスルーするのですが、エラーにしたい場合は -verify_return_error をつけます。

% openssl s_client -connect smtp.example.com:587 -starttls smtp -verify_return_error
...
Verification error: certificate has expired
--- 
New, TLSv1.3, Cipher is TLS_AES_256_GCM_SHA384
Secure Renegotiation IS NOT supported
Compression: NONE
Expansion: NONE
No ALPN negotiated
Early data was not sent
Verify return code: 10 (certificate has expired)
--- 
% 

証明書ホスト名の検証

証明書が正当でも自分がアクセスしてるサーバー用の証明書じゃない可能性もあります。 証明書内のホスト名を検証したい場合は -verify_hostname をつけます。

% openssl s_client -connect smtp.example.com:587 -starttls smtp -verify_return_error \
  -verify_hostname smtp.example.com

Ruby での SMTP

ここから本題。

Net::SMTP

Ruby で SMTP を使うには net/smtp ライブラリを使用します。めっちゃ簡単に使えます。

require 'net/smtp'

Net::SMTP.start('smtp.example.com', 25) do |smtp|
  smtp.send_message(<<EOS, 'sender@example.com', 'rcpt1@example.com', 'rcpt2@example.com')
From: sender@example.com
To: rcpt1@example.com
Cc: rcpt2@example.com
Subject: test

message body
EOS
end

SMTP認証

SMTP 認証も使用できます。

Net::SMTP.start('smtp.example.com', 587, 'client.example.net',
                'username', 'password') do |smtp|
  ...
end

ユーザー名とパスワードを指定したいだけなのに EHLO 名を書かないといけないのがちょっとイマイチです。

認証が必要なのは送信サーバーでメールを送るときなので、その場合は EHLO 名は重要ではないはずです。

そしてデフォルトの認証方式は PLAIN、つまり平文なんですが、デフォルトでは TLS は使われません 😇

STARTTLS

STARTTLS も簡単に使えるのですが、Net::SMTP.start ではダメで、Net::SMTP.new しないとダメなところがビミョーな感じです。

smtp = Net::SMTP.new('smtp.example.com', 587)
smtp.enable_starttls
smtp.start('client.example.com', 'username', 'password') do
  ...
end

465番ポートのように STARTTLS ではなく接続時から TLS したい場合は enable_starttls ではなく enable_tls を使います。

smtp = Net::SMTP.new('smtp.example.com', 465)
smtp.enable_tls
smtp.start('client.example.com', 'username', 'password') do
  ...
end

なお、 enable_starttlsenable_tls の両方を指定するとエラーになります。

証明書の検証

Net::SMTP はデフォルトでは TLS 証明書を検証しません。オレオレ証明書や期限切れ証明書でもスルーします。

証明書を検証するには次のようにします。

smtp = Net::SMTP.new('smtp.example.com', 587)
context = OpenSSL::SSL::SSLContext.new
context.set_params(verify_mode: OpenSSL::SSL::VERIFY_PEER)
smtp.enable_starttls(context)
smtp.start('client.example.com', 'username', 'password') do
  ...
end

OpenSSL ライブラリの使い方を知らないといけなくて、かなりダメな感じになってきましたね…。

証明書ホスト名の検証

Net::SMTP は、デフォルトでは証明書の検証をしないのに、なぜかホスト名の検証をしてるという妙な挙動をします。

そして、常に .new() または .start() の第1引数の文字列を使うので、テスト的に別のサーバー名を使うことはできません。

IPアドレスで接続するとホスト名の不適合でエラーになります。

smtp = Net::SMTP.new('192.168.1.2', 587)
context = OpenSSL::SSL::SSLContext.new
context.set_params(verify_mode: OpenSSL::SSL::VERIFY_PEER)
smtp.enable_starttls(context)
smtp.start('client.example.com', 'username', 'password')
#=> hostname "192.168.1.2" does not match the server
#   certificate (OpenSSL::SSL::SSLError)

Net::SMTP 改造

ということで、このイマイチなところをどうにかしたくて、https://github.com/ruby/net-smtp/ のコミット権をもらっていろいろいじってみました。

キーワード引数化

引数をキーワード引数化しました。

Net::SMTP.start(hostname, port, helo_name, username, password, authtype)

Net::SMTP.start(hostname, port, helo: helo_name,
                user: username, password: password, authtype: authtype)

これで EHLO 名を指定しなくても認証情報を指定できるようになりました。

Net::SMTP.start('smtp.example.com', 587,
                user: 'username', password: 'password') do |smtp|
  ...
end

デフォルトで STARTTLS を使用 [非互換]

サーバーが対応していれば自動的に STARTTLS を使用するようにしました。

EHLO の応答に STARTTLS があれば、とくに何も指定しなくても STARTTLS を使用します。

Net::SMTP.start(hostname, port) do |smtp|
  ...
end

ただし、STARTTLS を使用したくない場合はちょっと面倒です。

smtp = Net::SMTP.new(hostname, port)
smtp.disable_starttls
smtp.start { ... }

テスト環境とかで証明書がちゃんと設定されてないのに EHLOSTARTTLS を返すような環境ではエラーになってしまうかもしれないという意味で非互換です。

デフォルトで証明書を検証 [非互換]

証明書の検証もデフォルトで行います。これもオレオレ証明書等を使ってるような環境でエラーになってしまうので非互換です。

証明書を検証したくない場合用に tls_verify キーワード引数を追加しました。

Net::SMTP.start(hostname, port, tls_verify: false) { ... }

ホスト名の検証

証明書を検証しない時にもホスト名を検証するというバグっぽい挙動は修正しました。 tls_verify: false 時にはホスト名の検証は行いません。

そして接続に使用した名前と異なるホスト名で検証したい場合のために tls_hostname キーワード引数を追加しました。

こんな風に書けます。

Net::SMTP.start('192.168.1.2', 587, tls_hostname: 'smtp.example.com') { ... }

net-smtp gem

net/smtp ライブラリは Ruby 2.7 から Gem になってるので、新しい net/smtp を使いたい場合は次のようにすれば使えます。

% gem install net-smtp

Ruby 2.7 より前は、gem をインストールしても標準添付ライブラリの net/smtp が使われてしまいます(むりやり標準添付ライブラリの net/smtp.rb を消したりすれば使えないこともないですが…)。

証明書の検証まわりに非互換があるので、注意して使ってください。

Ruby の Git リポジトリにも入ったので、特に問題が発生しなければ Ruby 3.0 ではこの新しい net/smtp が標準になると思います。