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

в 11:47, , рубрики: email, IP, phpmailer, метки: ,

Добрый день!
Наверное все, кому приходилось отправлять почту из кода на 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.

Удачи в ваших начинаниях, друзья!

Автор: Ostrovski


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js