Добрый день!
Наверное все, кому приходилось отправлять почту из кода на 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();
Заключение
Подведем краткий итог:
- Исправлен баг в PHPMailer, из-за которого SmtpConnect() всегда возвращал true, даже в случае неудачной попытки подключения к SMTP-серверу.
- SmtpConnect() стал по-честному проверять все переданные ему MX-записи до первой удачной попытки.
- Написан новый плагин, с помощью которого можно отправлять почту через SMTP явно указывая какой сетевой интерфейс отправляющего сервера использовать.
- PHPMailer безболезненно для старого клиентского кода расширен для использования нового плагина SMTPX.
Удачи в ваших начинаниях, друзья!
Автор: Ostrovski