Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

[インデックス 12112] ファイルの概要

このコミットは、Go言語の net/smtp パッケージにおけるSMTPクライアントの挙動を改善するものです。具体的には、SMTPサーバーとの初期接続時に EHLO コマンドを優先的に使用し、それが失敗した場合に HELO コマンドにフォールバックするロジックを導入しています。これにより、RFCに厳密に従わない一部のSMTPサーバー(例: smtp.yandex.ru)との互換性が向上し、メール送信の問題が解決されます。

コミット

commit 2110fadd12a37d0ff4e899c8d3211dacc6332c5b
Author: Russ Cox <rsc@golang.org>
Date:   Tue Feb 21 16:39:02 2012 -0500

    net/smtp: use EHLO then HELO
    
    Before we were using "ESMTP" in the banner as a clue,
    but that is not required by the RFC and breaks mailing
    to smtp.yandex.ru.
    
    Fixes #3045.
    
    R=golang-dev, bradfitz
    CC=golang-dev
    https://golang.org/cl/5687066

GitHub上でのコミットページへのリンク

https://github.com/golang/go/commit/2110fadd12a37d0ff4e899c8d3211dacc6332c5b

元コミット内容

net/smtp: EHLOを使い、その後HELOを使うように変更。

以前はバナーに"ESMTP"が含まれているかどうかを手がかりにしていたが、これはRFCで必須とされておらず、smtp.yandex.ruへのメール送信を妨げていた。

Issue #3045を修正。

レビュー担当者: golang-dev, bradfitz CC: golang-dev 関連チェンジリスト: https://golang.org/cl/5687066

変更の背景

この変更は、Go言語の net/smtp パッケージが特定のSMTPサーバー(具体的には smtp.yandex.ru)に対してメールを送信できないという問題(Issue #3045)を解決するために行われました。

従来の net/smtp パッケージでは、SMTPサーバーから最初に受け取るバナーメッセージ(220応答)に "ESMTP" という文字列が含まれているかどうかをチェックし、その文字列が存在する場合にのみ EHLO コマンドを使用していました。しかし、SMTPの仕様を定義するRFC(Request for Comments)では、サーバーが EHLO をサポートしている場合に、そのバナーメッセージに必ずしも "ESMTP" という文字列を含めることを義務付けていません。

smtp.yandex.ru のような一部のSMTPサーバーは、EHLO をサポートしているにもかかわらず、初期バナーに "ESMTP" を含んでいませんでした。このため、GoのSMTPクライアントは EHLO を試行せず、代わりに古い HELO コマンドを使用していました。HELO コマンドでは利用できない拡張機能(例えば認証メカニズムなど)に依存する後続の処理が失敗し、結果としてメール送信ができないという問題が発生していました。

このコミットは、この非互換性を解消し、より堅牢なSMTPクライアントの挙動を実現するために、EHLO をまず試行し、それが失敗した場合に HELO にフォールバックするという標準的なアプローチを採用しました。

前提知識の解説

このコミットを理解するためには、以下のSMTPプロトコルに関する基本的な知識が必要です。

  • SMTP (Simple Mail Transfer Protocol): 電子メールをインターネット上で転送するための標準的なプロトコルです。メールクライアントからメールサーバーへ、またはメールサーバー間でメールを送信する際に使用されます。SMTPの通信は、クライアントとサーバー間のコマンドと応答のやり取りによって行われます。

  • HELO コマンド: SMTPセッションを開始するためにクライアントがサーバーに送信する最初のコマンドです。クライアントは自身のドメイン名またはIPアドレスをサーバーに通知します。これはSMTPの初期のRFC 821で定義された基本的な挨拶コマンドです。HELO は、基本的なSMTP接続を確立しますが、拡張機能のネゴシエーションは行いません。

  • EHLO コマンド (Extended HELO): HELO コマンドの拡張版であり、ESMTP (Extended Simple Mail Transfer Protocol) をサポートするクライアントが使用します。EHLO コマンドを送信することで、クライアントは自身がESMTPをサポートしていることをサーバーに伝え、サーバーがサポートするSMTPサービス拡張機能のリストを要求します。サーバーは、EHLO 応答として、認証 (AUTH)、TLS (STARTTLS)、メッセージサイズ宣言 (SIZE)、8BITMIMEなどの拡張機能のリストを返します。これにより、クライアントはより高度な機能を利用できるようになります。EHLO はRFC 1869で導入され、RFC 5321でサーバーでのサポートが必須となりました。

  • ESMTP (Extended Simple Mail Transfer Protocol): 元のSMTP標準に様々な拡張機能を追加したものです。ESMTPは、元のSMTPにはなかった認証、暗号化、より大きなメッセージの送信、国際化されたメールアドレスなどの機能を提供します。EHLO コマンドは、ESMTPの機能を利用するための鍵となります。

  • RFC (Request for Comments): インターネット技術の標準や仕様を定義する文書シリーズです。IETF (Internet Engineering Task Force) によって発行されます。SMTPおよびESMTPに関連する主要なRFCには以下のようなものがあります。

    • RFC 821: 元のSMTPの仕様。
    • RFC 1869: ESMTPと EHLO コマンドを定義。
    • RFC 2821: RFC 821を置き換え、ESMTP形式を再定義。
    • RFC 5321: SMTPの最新の定義であり、RFC 2821を更新し、EHLO サポートを必須としました。

このコミットのポイントは、RFCが EHLO をサポートするサーバーの初期バナーに "ESMTP" という文字列を含めることを義務付けていないという点です。そのため、バナーの文字列に依存するのではなく、まず EHLO を試行し、失敗した場合に HELO にフォールバックするという、より堅牢な実装が必要とされました。

技術的詳細

このコミットが解決しようとしている問題は、SMTPクライアントがサーバーとの初期ハンドシェイクを行う際のプロトコルネゴシエーションの不備にありました。

  1. 旧来の挙動: net/smtp パッケージの NewClient 関数は、SMTPサーバーから220応答(サービス準備完了のバナーメッセージ)を受け取った後、そのメッセージの内容を解析していました。具体的には、strings.Contains(msg, "ESMTP") を使用して、バナーメッセージに "ESMTP" という文字列が含まれているかどうかをチェックしていました。

    • もし "ESMTP" が含まれていれば、クライアントは c.ehlo() を呼び出して EHLO コマンドを送信していました。
    • "ESMTP" が含まれていなければ、クライアントは c.helo() を呼び出して HELO コマンドを送信していました。
  2. 問題点: このアプローチの問題点は、RFCがSMTPサーバーに対して、EHLO をサポートしている場合に必ずしも220応答のバナーに "ESMTP" という文字列を含めることを義務付けていない点にありました。多くのSMTPサーバーは慣習的に "ESMTP" を含んでいましたが、smtp.yandex.ru のように、EHLO をサポートしているにもかかわらず、この文字列を含まないサーバーも存在しました。 このようなサーバーに対しては、GoのSMTPクライアントは "ESMTP" 文字列を見つけられないため、HELO コマンドを送信してしまいます。その結果、サーバーが提供する EHLO 経由でしか利用できない拡張機能(例えば、認証メカニズム)がクライアントに認識されず、後続の認証やメール送信処理が失敗する原因となっていました。

  3. 解決策: このコミットでは、この問題を解決するために、プロトコルネゴシエーションのロジックをより堅牢なものに変更しました。新しいロジックは以下の通りです。

    • まず、サーバーからの220応答を受け取った後、バナーメッセージの内容に関わらず、常に c.ehlo() を呼び出して EHLO コマンドを試行します。
    • EHLO コマンドの送信が成功した場合(エラーが返されない場合)、そのまま処理を続行します。
    • EHLO コマンドの送信が失敗した場合(エラーが返された場合)、そのエラーを無視し、代わりに c.helo() を呼び出して HELO コマンドを試行します。

この「まず EHLO を試行し、失敗したら HELO にフォールバックする」というアプローチは、SMTPプロトコルにおける一般的なベストプラクティスであり、RFC 5321にも準拠しています。これにより、バナーメッセージの内容に依存することなく、サーバーが EHLO をサポートしていればその機能を利用し、サポートしていなければ HELO に切り替えることで、幅広いSMTPサーバーとの互換性を確保できるようになりました。

コアとなるコードの変更箇所

このコミットによる主要なコード変更は、以下の2つのファイルにあります。

  1. src/pkg/net/smtp/smtp.go

    • NewClient 関数のロジックが変更されました。
  2. src/pkg/net/smtp/smtp_test.go

    • faker 構造体に net.Conn インターフェースのメソッドが追加されました。
    • TestNewClient および TestNewClient2 という新しいテストケースが追加されました。

src/pkg/net/smtp/smtp.go の変更点

--- a/src/pkg/net/smtp/smtp.go
+++ b/src/pkg/net/smtp/smtp.go
@@ -50,15 +50,14 @@ func Dial(addr string) (*Client, error) {
 // server name to be used when authenticating.
 func NewClient(conn net.Conn, host string) (*Client, error) {
 	text := textproto.NewConn(conn)
-	_, msg, err := text.ReadResponse(220)
+	_, _, err := text.ReadResponse(220)
 	if err != nil {
 		text.Close()
 		return nil, err
 	}
 	c := &Client{Text: text, conn: conn, serverName: host}
-	if strings.Contains(msg, "ESMTP") {
-		err = c.ehlo()
-	} else {
+	err = c.ehlo()
+	if err != nil {
 		err = c.helo()
 	}
 	return c, err

src/pkg/net/smtp/smtp_test.go の変更点

--- a/src/pkg/net/smtp/smtp_test.go
+++ b/src/pkg/net/smtp/smtp_test.go
@@ -8,9 +8,11 @@ import (
 	"bufio"
 	"bytes"
 	"io"
+	"net"
 	"net/textproto"
 	"strings"
 	"testing"
+	"time"
 )
 
 type authTest struct {
@@ -59,9 +61,12 @@ type faker struct {
 	io.ReadWriter
 }
 
-func (f faker) Close() error {
-	return nil
-}
+func (f faker) Close() error                     { return nil }
+func (f faker) LocalAddr() net.Addr              { return nil }
+func (f faker) RemoteAddr() net.Addr             { return nil }
+func (f faker) SetDeadline(time.Time) error      { return nil }
+func (f faker) SetReadDeadline(time.Time) error  { return nil }
+func (f faker) SetWriteDeadline(time.Time) error { return nil }
 
 func TestBasic(t *testing.T) {
 	basicServer = strings.Join(strings.Split(basicServer, "\n"), "\r\n")
@@ -180,3 +185,87 @@ Goodbye.
 .
 QUIT
 `
+
+func TestNewClient(t *testing.T) {
+	newClientServer = strings.Join(strings.Split(newClientServer, "\n"), "\r\n")
+	newClientClient = strings.Join(strings.Split(newClientClient, "\n"), "\r\n")
+
+	var cmdbuf bytes.Buffer
+	bcmdbuf := bufio.NewWriter(&cmdbuf)
+	out := func() string {
+		bcmdbuf.Flush()
+		return cmdbuf.String()
+	}
+	var fake faker
+	fake.ReadWriter = bufio.NewReadWriter(bufio.NewReader(strings.NewReader(newClientServer)), bcmdbuf)
+	c, err := NewClient(fake, "fake.host")
+	if err != nil {
+		t.Fatalf("NewClient: %v\n(after %v)", err, out())
+	}
+	if ok, args := c.Extension("aUtH"); !ok || args != "LOGIN PLAIN" {
+		t.Fatalf("Expected AUTH supported")
+	}
+	if ok, _ := c.Extension("DSN"); ok {
+		t.Fatalf("Shouldn't support DSN")
+	}
+	if err := c.Quit(); err != nil {
+		t.Fatalf("QUIT failed: %s", err)
+	}
+
+	actualcmds := out()
+	if newClientClient != actualcmds {
+		t.Fatalf("Got:\n%s\nExpected:\n%s", actualcmds, newClientClient)
+	}
+}
+
+var newClientServer = `220 hello world
+250-mx.google.com at your service
+250-SIZE 35651584
+250-AUTH LOGIN PLAIN
+250 8BITMIME
+221 OK
+`
+
+var newClientClient = `EHLO localhost
+QUIT
+`
+
+func TestNewClient2(t *testing.T) {
+	newClient2Server = strings.Join(strings.Split(newClient2Server, "\n"), "\r\n")
+	newClient2Client = strings.Join(strings.Split(newClient2Client, "\n"), "\r\n")
+
+	var cmdbuf bytes.Buffer
+	bcmdbuf := bufio.NewWriter(&cmdbuf)
+	var fake faker
+	fake.ReadWriter = bufio.NewReadWriter(bufio.NewReader(strings.NewReader(newClient2Server)), bcmdbuf)
+	c, err := NewClient(fake, "fake.host")
+	if err != nil {
+		t.Fatalf("NewClient: %v", err)
+	}
+	if ok, _ := c.Extension("DSN"); ok {
+		t.Fatalf("Shouldn't support DSN")
+	}
+	if err := c.Quit(); err != nil {
+		t.Fatalf("QUIT failed: %s", err)
+	}
+
+	bcmdbuf.Flush()
+	actualcmds := cmdbuf.String()
+	if newClient2Client != actualcmds {
+		t.Fatalf("Got:\n%s\nExpected:\n%s", actualcmds, newClient2Client)
+	}
+}
+
+var newClient2Server = `220 hello world
+502 EH?
+250-mx.google.com at your service
+250-SIZE 35651584
+250-AUTH LOGIN PLAIN
+250 8BITMIME
+221 OK
+`
+
+var newClient2Client = `EHLO localhost
+HELO localhost
+QUIT
+`

コアとなるコードの解説

src/pkg/net/smtp/smtp.go の変更点

NewClient 関数は、SMTPクライアントの新しいインスタンスを作成し、サーバーとの初期ハンドシェイクを行います。

  • 変更前:

    _, msg, err := text.ReadResponse(220)
    // ...
    if strings.Contains(msg, "ESMTP") {
        err = c.ehlo()
    } else {
        err = c.helo()
    }
    

    このコードでは、サーバーからの220応答メッセージ (msg) を解析し、"ESMTP" という文字列が含まれているかどうかで ehlo() を呼び出すか helo() を呼び出すかを決定していました。これが、前述の問題の原因となっていました。

  • 変更後:

    _, _, err := text.ReadResponse(220) // msg変数は不要になったため破棄
    // ...
    err = c.ehlo() // まずEHLOを試行
    if err != nil { // EHLOが失敗した場合
        err = c.helo() // HELOにフォールバック
    }
    

    この変更により、NewClient 関数はまず c.ehlo() を呼び出して EHLO コマンドを試行します。ehlo() メソッドがエラーを返した場合(つまり、サーバーが EHLO をサポートしていない、または何らかの理由で EHLO コマンドの処理に失敗したと判断された場合)、そのエラーを無視して c.helo() を呼び出し、HELO コマンドにフォールバックします。このロジックは、SMTPプロトコルのベストプラクティスに沿ったものであり、より堅牢なクライアントの挙動を実現します。

src/pkg/net/smtp/smtp_test.go の変更点

テストファイルには、net.Conn インターフェースを模倣するための faker 構造体への追加と、新しいテストケースが導入されています。

  • faker 構造体への追加: net.Conn インターフェースは、ネットワーク接続を表すGoの標準インターフェースです。net/smtp パッケージの NewClient 関数は net.Conn 型の引数を取ります。テストにおいて実際のネットワーク接続を確立する代わりに、faker 構造体を使って net.Conn の挙動をシミュレートしています。 変更前は Close() メソッドしか実装されていませんでしたが、LocalAddr(), RemoteAddr(), SetDeadline(), SetReadDeadline(), SetWriteDeadline() といった net.Conn インターフェースの他のメソッドも追加されました。これらはテストの目的上、単に nilnil エラーを返すダミーの実装ですが、これにより fakernet.Conn インターフェースを完全に満たすようになり、より広範なテストシナリオに対応できるようになります。

  • TestNewClient の追加: このテストケースは、サーバーが EHLO を正常に処理し、拡張機能(例: AUTH)を返す一般的なシナリオをシミュレートします。 newClientServer 変数で定義されたサーバー応答は、220バナーの後に EHLO 応答として 250-AUTH LOGIN PLAIN などの拡張機能を含んでいます。 テストでは、NewClientEHLO を送信し、AUTH 拡張が正しく認識されることを検証しています。newClientClient 変数には、クライアントが送信すべきコマンド(EHLO localhostQUIT)が定義されており、実際の送信コマンドと比較されます。

  • TestNewClient2 の追加: このテストケースは、サーバーが EHLO コマンドに対してエラー応答(例: 502 EH?)を返し、クライアントが HELO にフォールバックするシナリオをシミュレートします。 newClient2Server 変数で定義されたサーバー応答は、220バナーの後に EHLO に対して 502 EH? というエラーを返します。その後、HELO に対しては正常な応答を返します。 テストでは、NewClient がまず EHLO を試行し、エラーを受け取った後に HELO にフォールバックし、最終的に QUIT コマンドを送信する一連の挙動を検証しています。newClient2Client 変数には、クライアントが送信すべきコマンド(EHLO localhostHELO localhostQUIT)が定義されており、実際の送信コマンドと比較されます。

これらのテストケースは、EHLOHELO のネゴシエーションロジックが意図通りに機能し、特に EHLO が失敗した場合のフォールバックメカニズムが正しく動作することを保証するために重要です。

関連リンク

  • Go CL (Change List): https://golang.org/cl/5687066
  • Go Issue: #3045 (https://code.google.com/p/go/issues/detail?id=3045) - このコミットが修正した問題のトラッキング

参考にした情報源リンク