Как стать автором
Поиск
Написать публикацию
Обновить

Расширяем возможности PHPMailer

Время на прочтение11 мин
Количество просмотров42K
Добрый день!
Наверное все, кому приходилось отправлять почту из кода на PHP через SMTP, знакомы с классом PHPMailer.
В статье я расскажу о том, как можно в несколько строк кода научить PHPMailer принимать в качестве дополнительного параметра IP адрес сетевого интерфейса, с которого мы хотим осуществить отправку. Естественно, что эта возможность будет полезна только на серверах с несколькими белыми IP адресами. А в качестве небольшого дополнения мы отловим достаточно неприятного жучка из кода PHPMailer`а.

Обзор архитектуры PHPMailer


Пакет PHPMailer состоит из одноименного фронтэнда (класс PHPMailer) и нескольких классов-плагинов, реализующих возможность отправки почты по протоколу SMTP, в том числе и с предварительной аутентификацией по POP3.

Фронтэнд PHPMailer предоставляет поля и методы по установке параметров письма (localhost, return-path, AddAdress(), body, from и пр.), выбору способа отправки и способа аутентификации (SMTPSecure, SMTPAuth, IsMail(), IsSendMail(), IsSMTP() и пр.), а также метод Send().

Установив параметры письма и указав способ отправки (возможно выбрать из следующих: mail, sendmail, qmail или smtp), необходимо вызвать метод класса PHPMailer Send(), который, в свою очередь, делегирует вызов внутреннему методу, отвечающему за отправку почты тем или иным способом. Так как нас интересует именно SMTP, то далее в основном мы будем рассматривать плагин SMTP из файла class.smtp.php.

При использовании метода PHPMailer::IsSMTP() метод PHPMailer::Send() вызовет защищенный метод PHPMailer::SmtpSend($header, $body), передав ему сформированные заголовки и тело письма.

Метод PHPMailer::SmtpSend() попытается подключиться к удаленному SMTP-серверу получателя (если это уже не первая отправка письма объектом PHPMailer, то скорее всего соединение уже было установлено и этот шаг будет пропущен) и инициировать с ним стандартную SMTP-сессию (HELLO/EHLO, MAIL TO, RCPT, DATA и т.д.).

Соединение с SMTP-сервером происходит в публичном методе PHPMailer::SmtpConnect(). Так как для одного домена может быть сразу несколько MX-записей с различными приоритетами, то метод PHPMailer::SmtpConnect() попытается последовательно соединиться с каждым из SMTP-серверов, указанных при конфигурировании PHPMailer.

Жучок в коде


А теперь внимательно посмотрим на код PHPMailer::SmtpConnect():
/**
  * Initiates a connection to an SMTP server.
  * Returns false if the operation failed.
  * @uses SMTP
  * @access public
  * @return bool
  */
public function SmtpConnect() 
{
    if(is_null($this->smtp)) {
        $this->smtp = new SMTP();
    }

    $this->smtp->do_debug = $this->SMTPDebug;
    $hosts = explode(';', $this->Host);
    $index = 0;
    $connection = $this->smtp->Connected();

    // Retry while there is no connection
    try {
        while($index < count($hosts) && !$connection) {
            $hostinfo = array();
            if (preg_match('/^(.+):([0-9]+)$/', $hosts[$index], $hostinfo)) {
                $host = $hostinfo[1];
                $port = $hostinfo[2];
            } else {
                $host = $hosts[$index];
                $port = $this->Port;
            }

            $tls = ($this->SMTPSecure == 'tls');
            $ssl = ($this->SMTPSecure == 'ssl');

            if ($this->smtp->Connect(($ssl ? 'ssl://':'').$host, $port, $this->Timeout)) {
                $hello = ($this->Helo != '' ? $this->Helo : $this->ServerHostname());
                $this->smtp->Hello($hello);

                if ($tls) {
                    if (!$this->smtp->StartTLS()) {
                        throw new phpmailerException($this->Lang('tls'));
                    }

                    //We must resend HELO after tls negotiation
                   $this->smtp->Hello($hello);
                }

                $connection = true;
                if ($this->SMTPAuth) {
                     if (!$this->smtp->Authenticate($this->Username, $this->Password)) {
                        throw new phpmailerException($this->Lang('authenticate'));
                     }
                }
            }
            $index++;
            if (!$connection) {
                throw new phpmailerException($this->Lang('connect_host'));
            }
        }
    } catch (phpmailerException $e) {
           $this->smtp->Reset();
	   if ($this->exceptions) {
               throw $e;
           }
    }
    return true;
}

В коде $this->smtp — это объект класса-плагина SMTP.

Постараемся разобраться, что же авторы имели в виду. Для начала выполняется проверка, создан ли внутренний объект, умеющий работать с SMTP и выполняется его создание, если это первый вызов метода SmtpConnect() объекта класса PHPMailer (на самом деле еще метод PHPMailer::Close() может превратить $this->smtp в null).

Затем поле PHPMailer::Host разбивается по разделителю ';' и в итоге получается массив MX-записей для домена получателя. Если в Host была всего одна запись (например, 'smtp.yandex.ru'), то в массиве будет всего один элемент.

Далее выполняется проверка, а не подключены ли мы уже к серверу получателя. Если это первый вызов SmtpConnect(), то очевидно, что $connection будет false.

Вот мы и добрались до самого интересного. Начинается цикл по всем MX-записям, в каждой итерации которого производится попытка подключения к очередному MX. Но что будет, если выполнить в голове алгоритм этого цикла, представив, что для первой MX-записи if ($this->smtp->Connect(($ssl? 'ssl://':'').$host, $port, $this->Timeout)) вернула false? Окажется, что цикл бросит исключение, которое будет перехвачено уже за циклом. Т.е. все остальные MX-записи не будут проверены на доступность и мы поймаем исключение.

Но это еще не самое неприятное. PHPMailer умеет работать в двух режимах — бросать исключения, либо же тихо умирать с записью сообщения об ошибке в поле ErrorInfo. Так вот в случае использования тихого режима ($this->exceptions == false, причем это режим по умолчанию) SmtpConnect() вернет true!

В общем этот баг отнял у меня некоторое время, разработчики о нем оповещены. Я его заметил в версии 5.2.1, но и более старые версии ведут себя так же.

Прежде чем двигаться дальше, представлю свой быстрый фикс. До выхода официального исправления от разработчиков живу с ним. Уже месяц полет нормальный.
public function SmtpConnect() 
{
    if(is_null($this->smtp)) {
        $this->smtp = new SMTP();
    }

    $this->smtp->do_debug = $this->SMTPDebug;
    $hosts = explode(';', $this->Host);
    $index = 0;
    $connection = $this->smtp->Connected();

    // Retry while there is no connection
    try {
        while($index < count($hosts) && !$connection) {
          $hostinfo = array();
          if (preg_match('/^(.+):([0-9]+)$/', $hosts[$index], $hostinfo)) {
              $host = $hostinfo[1];
              $port = $hostinfo[2];
          } else {
              $host = $hosts[$index];
              $port = $this->Port;
          }

          $tls = ($this->SMTPSecure == 'tls');
          $ssl = ($this->SMTPSecure == 'ssl');

          $bRetVal = $this->smtp->Connect(($ssl ? 'ssl://':'').$host, $port, $this->Timeout);
          if ($bRetVal) {
              $hello = ($this->Helo != '' ? $this->Helo : $this->ServerHostname());
              $this->smtp->Hello($hello);

            if ($tls) {
                  if (!$this->smtp->StartTLS()) {
                      throw new phpmailerException($this->Lang('tls'));
                  }

                  //We must resend HELO after tls negotiation
                  $this->smtp->Hello($hello);
              }

              if ($this->SMTPAuth) {
                  if (!$this->smtp->Authenticate($this->Username, $this->Password)) {
                    throw new phpmailerException($this->Lang('authenticate'));
                  }
              }

              $connection = true;
              break;
          }
          $index++;
      }

      if (!$connection) {
          throw new phpmailerException($this->Lang('connect_host'));
      }
    } catch (phpmailerException $e) {
        $this->SetError($e->getMessage());
        if ($this->smtp->Connected())
            $this->smtp->Reset();
        if ($this->exceptions) {
                throw $e;
        }
        return false;
    }
    return true;
}


Расширяем PHPMailer для работы с несколькими сетевыми интерфейсами


Плагин SMTP PHPMailer`а работает с сетью через fsockopen, fputs и fgets. Если на нашей машине несколько сетевых интерфейсов, смотрящих в Интернет, fsockopen в любом случае создаст сокет на первом соединении. Нам же необходимо уметь создавать на любом.

Первая мысль, которая пришла в голову — это использовать стандартную связку классических сокетов socket_create, socket_bind, socket_connect, которая в socket_bind позволяет указать с каким сетевым интерфейсом связать сокет, указав его IP адрес. Как оказалось, мысль не совсем удачная. В результате пришлось переписать практически весь плагин PHPMailer`а SMTP, заменив в нем fputs и fgets на socket_read и socket_write, потому что fputs и fgets не умеют работать с ресурсом, созданным socket_create. Заработало, но на душе остался осадок.

Следующая мысль оказалась удачнее. Существует же функция stream_socket_client, создающая потоковый сокет, который можно благополучно читать fgets`ом! В результате, заменив всего один метод в плагине SMTP, можно научить PHPMailer отсылать почту с явным указанием сетевого интерфейса, и при этом практически не трогать код разработчиков.

Наш плагин выглядит следующим образом:
require_once 'class.smtp.php';

class SMTPX extends SMTP
{
    public function __construct()
    {
        parent::__construct();
    }

    public function Connect($host, $port = 0, $tval = 30, $local_ip)
    {
        // set the error val to null so there is no confusion
        $this->error = null;

        // make sure we are __not__ connected
        if($this->connected()) {
            // already connected, generate error
            $this->error = array("error" => "Already connected to a server");
            return false;
        }

        if(empty($port)) {
            $port = $this->SMTP_PORT;
        }

        $opts = array(
            'socket' => array(
                'bindto' => "$local_ip:0",
            ),
        );

        // create the context...
        $context = stream_context_create($opts);

        // connect to the smtp server
        $this->smtp_conn = @stream_socket_client($host.':'.$port,
                                                 $errno,
                                                 $errstr,
                                                 $tval,  // give up after ? secs
                                                 STREAM_CLIENT_CONNECT,
                                                 $context);

        // verify we connected properly
        if(empty($this->smtp_conn)) {
            $this->error = array("error" => "Failed to connect to server",
                "errno" => $errno,
                "errstr" => $errstr);
            if($this->do_debug >= 1) {
                echo "SMTP -> ERROR: " . $this->error["error"] . ": $errstr ($errno)" . $this->CRLF . '<br />';
            }
            return false;
        }

        // SMTP server can take longer to respond, give longer timeout for first read
        // Windows does not have support for this timeout function
        if(substr(PHP_OS, 0, 3) != "WIN")
            socket_set_timeout($this->smtp_conn, $tval, 0);

        // get any announcement
        $announce = $this->get_lines();

        if($this->do_debug >= 2) {
            echo "SMTP -> FROM SERVER:" . $announce . $this->CRLF . '<br />';
        }

        return true;
    }
} 


На самом деле реализация метода Connect() тоже изменилась минимально. Заменены лишь строки, создающие непосредственно сокет и в сигнатуру добавлен еще одни параметр — IP адрес сетевого интерфейса.

Чтобы использовать этот плагин, нужно расширить класс PHPMailer следующим образом:
require_once 'class.phpmailer.php';

class MultipleInterfaceMailer extends PHPMailer
{
    /**
     * IP адрес сетевого интерфейса, с которого нужно
     * подключаться к удаленному SMTP-серверу.
     * Используется при работе через плагин SMTPX.
     * @var string
     */
    public $Ip                = '';

    public function __construct($exceptions = false)
    {
        parent::__construct($exceptions);
    }

    /**
     * Метод для работы с плагином SMTPX.
     * @param string $ip IP адрес сетевого интерфейса с доступом в Интернет.
     */
    public function IsSMTPX($ip = '') {
        if ('' !== $ip)
            $this->Ip = $ip;
        $this->Mailer = 'smtpx';
    }

    protected function PostSend()
    {
        if ('smtpx' == $this->Mailer) {
            $this->SmtpSend($this->MIMEHeader, $this->MIMEBody);
            return;
        }

        parent::PostSend();
    }

    /**
     * Внесены изменения, касающиеся отправки писем с явным указанием
     * IP адреса сетевого интерфейса компьютера.
     * @param string $header The message headers
     * @param string $body The message body
     * @uses SMTP
     * @access protected
     * @return bool
     */
    protected function SmtpSend($header, $body)
    {
        require_once $this->PluginDir . 'class.smtpx.php';
        $bad_rcpt = array();

        if(!$this->SmtpConnect()) {
            throw new phpmailerException($this->Lang('connect_host'), self::STOP_CRITICAL);
        }
        $smtp_from = ($this->Sender == '') ? $this->From : $this->Sender;
        if(!$this->smtp->Mail($smtp_from)) {
            throw new phpmailerException($this->Lang('from_failed') . $smtp_from, self::STOP_CRITICAL);
        }

        // Attempt to send attach all recipients
        foreach($this->to as $to) {
            if (!$this->smtp->Recipient($to[0])) {
                $bad_rcpt[] = $to[0];
                // implement call back function if it exists
                $isSent = 0;
                $this->doCallback($isSent, $to[0], '', '', $this->Subject, $body);
            } else {
                // implement call back function if it exists
                $isSent = 1;
                $this->doCallback($isSent, $to[0], '', '', $this->Subject, $body);
            }
        }
        foreach($this->cc as $cc) {
            if (!$this->smtp->Recipient($cc[0])) {
                $bad_rcpt[] = $cc[0];
                // implement call back function if it exists
                $isSent = 0;
                $this->doCallback($isSent, '', $cc[0], '', $this->Subject, $body);
            } else {
                // implement call back function if it exists
                $isSent = 1;
                $this->doCallback($isSent, '', $cc[0], '', $this->Subject, $body);
            }
        }
        foreach($this->bcc as $bcc) {
            if (!$this->smtp->Recipient($bcc[0])) {
                $bad_rcpt[] = $bcc[0];
                // implement call back function if it exists
                $isSent = 0;
                $this->doCallback($isSent, '', '', $bcc[0], $this->Subject, $body);
            } else {
                // implement call back function if it exists
                $isSent = 1;
                $this->doCallback($isSent, '', '', $bcc[0], $this->Subject, $body);
            }
        }


        if (count($bad_rcpt) > 0 ) { //Create error message for any bad addresses
            $badaddresses = implode(', ', $bad_rcpt);
            throw new phpmailerException($this->Lang('recipients_failed') . $badaddresses);
        }
        if(!$this->smtp->Data($header . $body)) {
            throw new phpmailerException($this->Lang('data_not_accepted'), self::STOP_CRITICAL);
        }
        if($this->SMTPKeepAlive == true) {
            $this->smtp->Reset();
        }
        return true;
    }

    /**
     * Внесены изменения, расширяющие класс PHPMailer для
     * работы с плагином SMTPX.
     * @uses SMTP
     * @access public
     * @return bool
     */
    public function SmtpConnect() {
        if(is_null($this->smtp) || !($this->smtp instanceof SMTPX)) {
            $this->smtp = new SMTPX();
        }

        $this->smtp->do_debug = $this->SMTPDebug;
        $hosts = explode(';', $this->Host);
        $index = 0;
        $connection = $this->smtp->Connected();

        // Retry while there is no connection
        try {
            while($index < count($hosts) && !$connection) {
                $hostinfo = array();
                if (preg_match('/^(.+):([0-9]+)$/', $hosts[$index], $hostinfo)) {
                    $host = $hostinfo[1];
                    $port = $hostinfo[2];
                } else {
                    $host = $hosts[$index];
                    $port = $this->Port;
                }

                $tls = ($this->SMTPSecure == 'tls');
                $ssl = ($this->SMTPSecure == 'ssl');

                $bRetVal = $this->smtp->Connect(($ssl ? 'ssl://':'').$host, $port, $this->Timeout, $this->Ip);
                if ($bRetVal) {

                    $hello = ($this->Helo != '' ? $this->Helo : $this->ServerHostname());
                    $this->smtp->Hello($hello);

                    if ($tls) {
                        if (!$this->smtp->StartTLS()) {
                            throw new phpmailerException($this->Lang('tls'));
                        }

                        //We must resend HELO after tls negotiation
                        $this->smtp->Hello($hello);
                    }

                    if ($this->SMTPAuth) {
                        if (!$this->smtp->Authenticate($this->Username, $this->Password)) {
                            throw new phpmailerException($this->Lang('authenticate'));
                        }
                    }

                    $connection = true;
                    break;
                }
                $index++;
            }

            if (!$connection) {
                throw new phpmailerException($this->Lang('connect_host'));
            }
        } catch (phpmailerException $e) {
            $this->SetError($e->getMessage());
            if ($this->smtp->Connected())
                $this->smtp->Reset();
            if ($this->exceptions) {
                throw $e;
            }
            return false;
        }
        return true;
    }
}


В класс MultipleInterfaceMailer добавлено новое открытое поле Ip, которое должно быть установлено строковым представлением IP адреса сетевого интерфейса, с которого мы хотим отправлять почту. Также добавлен метод IsSMTPX(), указывающий, что письма нужно отправлять с использованием нового плагина. Методы PostSend(), SmtpSend() и SmtpConnect() также переделаны для использования плагина SMTPX. При этом объекты класса MultipleInterfaceMailer можно спокойно использовать с существующим клиентским кодом, который, например, отправляет почту через sendmail или через оригинальный плагин SMTP, так как ни процедура использования, ни интерфейс класса не изменились.

Далее небольшой пример использования нового класса:
function getSmtpHostsByDomain($sRcptDomain)
{
    if (getmxrr($sRcptDomain, $aMxRecords, $aMxWeights)) {
        if (count($aMxRecords) > 0) {
            for ($i = 0; $i < count($aMxRecords); ++$i) {
                $mxs[$aMxRecords[$i]] = $aMxWeights[$i];
            }

            asort($mxs);

            $aSortedMxRecords = array_keys($mxs);
            $sResult = '';
            foreach ($aSortedMxRecords as $r) {
                $sResult .= $r . ';';
            }

            return $sResult;
        }
    }

    //Функция getmxrr возвращает только почтовые сервера, найденные в DNS,
    //однако, согласно RFC 2821, когда в списке нет почтовых серверов,
    //необходимо использовать только $sRcptDomain в качестве почтового сервера с
    //приоритетом 0.
    return $sRcptDomain;
}


require 'MultipleInterfaceMailer.php';


$mailer = new MultipleInterfaceMailer(true);
$mailer->IsSMTPX('192.168.1.1');  //Здесь необходимо указать IP адрес желаемого интерфейса
//$mailer->IsSMTP(); а можно и по старинке
$mailer->Host = getSmtpHostsByDomain('email.net');
$mailer->Body = 'blah-blah';
$mailer->From ='no-replay@yourdomain.net';
$mailer->AddAddress('sucreface@email.net');

$mailer->Send();


Заключение


Подведем краткий итог:
  1. Исправлен баг в PHPMailer, из-за которого SmtpConnect() всегда возвращал true, даже в случае неудачной попытки подключения к SMTP-серверу.
  2. SmtpConnect() стал по-честному проверять все переданные ему MX-записи до первой удачной попытки.
  3. Написан новый плагин, с помощью которого можно отправлять почту через SMTP явно указывая какой сетевой интерфейс отправляющего сервера использовать.
  4. PHPMailer безболезненно для старого клиентского кода расширен для использования нового плагина SMTPX.

Удачи в ваших начинаниях, друзья!
Теги:
Хабы:
Всего голосов 26: ↑24 и ↓2+22
Комментарии9

Публикации

Ближайшие события