Как стать автором
Обновить

Расширяем возможности 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.

Удачи в ваших начинаниях, друзья!
Теги:
Хабы:
+22
Комментарии9

Публикации

Изменить настройки темы

Истории

Работа

PHP программист
155 вакансий

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

Weekend Offer в AliExpress
Дата20 – 21 апреля
Время10:00 – 20:00
Место
Онлайн
Конференция «Я.Железо»
Дата18 мая
Время14:00 – 23:59
Место
МоскваОнлайн