Ruby Net::SMTP
これは 富士通クラウドテクノロジーズ Advent Calendar 2020 と FUJITSU 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_starttls
と enable_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 { ... }
テスト環境とかで証明書がちゃんと設定されてないのに EHLO
で STARTTLS
を返すような環境ではエラーになってしまうかもしれないという意味で非互換です。
デフォルトで証明書を検証 [非互換]
証明書の検証もデフォルトで行います。これもオレオレ証明書等を使ってるような環境でエラーになってしまうので非互換です。
証明書を検証したくない場合用に 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
が標準になると思います。