appendTransport($transport); * * // Обратный адрес по умолчанию * $layer->appendTrigger('beforeSend', function(Message $message){ * if ( !$message->getSenderEmail() ) { * $message->setSenderEmail('default@email'); * } * }); * * // Логирование ошибок * $layer->appendTrigger('error', function(Message $message, TransportInterface $transport){ * log('Cообщение %s для %s не было доставлено. Ошибка: %s', * $message->getId(), * $message->getRecipients(', '), * $transport->getLastError() * ); * }); * * // Логирование отправленных сообщений * $layer->appendTrigger('afterSend', function(Message $message){ * log('Отправлено сообщение %s для %s от %s.', * $message->getId(), * $message->getRecipients(', '), * $message->senderEmail() * ); * }); * * $message = new Message; * $messate->addRecipient('foo@bar'); * $message->setContent('Привет, мир!'); * * if ( !$layer->send($message) ) { * var_dump($layer->getErrors()); * } * */ class Layer { use Triggers; private $transports = []; private $errors = []; public function appendTranspport(TransportInterface $transport, callable $filter = null) { $this->transports[] = [$transport, $filter]; return $this; } public function send(Message $message) { $this->errors = []; $this->launchTriggers('beforeSend', $message, $this); /* @var $transport TransportInterface */ foreach ($this->transports as list($transport, $filter)) { if ($filter) { if (!$filter($message)) { continue; } } if (!$transport->send($message)) { $this->launchTriggers('error', $message, $transport, $this); $this->errors[] = $transport->name() .': '. $transport->getLastError(); } } $this->launchTriggers('afterSend', $message, $this); return empty($this->errors); } public function getErrors() { return $this->errors; } } trait Triggers { private $triggers = []; public function appendTrigger($category, callable $function) { if (!isset($this->triggers[$category])) { $this->triggers[$category] = []; } $this->triggers[$category][] = $function; return $this; } private function launchTriggers($category, ...$args) { if (isset($this->triggers[$category])) { foreach ( $this->triggers[$category] as $function ) { $function(...$args); } } } } interface TransportInterface { /** * Название транспорта. * * @return string */ public function name(); /** * Отправление сообщения. * * @param \ApMailer\Message $message * @return bool */ public function send(Message $message); /** * Последняя ошибка. * @return string */ public function getLastError(); } class Exception extends \Exception { } function lastPhpErrorMessage($default = '') { $error = error_get_last(); return isset($error['message']) ? $error['message'] : $default; } class Smtp implements TransportInterface { private $login = null; private $password = null; private $host = null; private $port = 25; private $smtpAuth = true; private $timeout = 10; private $useSSL = false; private $lastError = null; private $lastErrorCode = null; private $socket = null; public function __construct(array $options = null) { if ( $options ) { $opt = function($name, $default = null) use ($options) { return isset($options[$name]) ? $options[$name] : $default; }; $bool = function($name, $default = false) use ($opt) { return in_array((string) $opt($name, $default), ['true', '1']); }; $this->login = $opt('login' , $this->login); $this->password = $opt('password', $this->password); $this->host = $opt('host' , $this->host); $this->port = $opt('port' , $this->port); $this->timeout = $opt('timeout' , $this->timeout); $this->useSSL = $bool('ssl' , $this->useSSL); $this->smtpAuth = $bool('auth', strlen($this->login) > 0); } } /** * {@inheritdoc} */ public function name() { return __CLASS__; } /** * {@inheritdoc} */ public function getLastError() { return $this->lastError; } /** * {@inheritdoc} */ public function send(Message $message) { $this->lastError = null; try { $this->openSocket(); if ($this->smtpAuth) { $this->smtpSend(250, 'EHLO [192.168.0.1]', 'Соединение с сервером с авторизацией'); $this->smtpSend(334, 'AUTH LOGIN', 'Авторизация'); $this->smtpSend(334, base64_encode($this->login), 'Авторизация'); $this->smtpSend(235, base64_encode($this->password), 'Авторизация'); } else { $this->smtpSend(250, 'HELO [192.168.0.1]', 'Соединение с сервером без авторизации'); } $this->smtpSend(250, "MAIL FROM:<{$message->getSenderEmail()}>", 'Объявление отправителя'); foreach ($message->getRecipientsEmailOnly() as $recipient) { $this->smtpSend(250, "RCPT TO:<{$recipient}>", 'Объявление получателя'); } $this->smtpSend(354, 'DATA', 'Отправление письма'); $this->smtpSend(250, $message . "\r\n.", 'Отправление письма'); $this->smtpSend(221, 'QUIT', 'Завершение'); $this->closeSocket(); } catch (Exception $e) { $this->closeSocket(); $this->lastError = $e->getCode() .': '. $e->getMessage(); $this->lastErrorCode = $e->getCode(); return false; } return true; } private function openSocket() { $host = ''; if ($this->useSSL) { $host .= 'ssl://'; } $host .= "{$this->host}:{$this->port}"; $this->socket = @stream_socket_client($host, $errorNumber, $errorDescription, 2); if (!is_resource($this->socket)) { throw new Exception(sprintf('Не удалось подключиться к %s:%s. Ошибка %s: %s.', (string) $this->host, (string) $this->port, (string) $errorNumber, (string) $errorDescription ), 100); } stream_set_timeout($this->socket, $this->timeout); $line = $this->socketRead(); if (!preg_match('/^220\s/', $line)) { throw new Exception('Не удалось установить соединение. Сервер вернул неожиданный ответ: '. $line, 101); } } private function smtpSend($expectedCode, $command, $stage) { $this->socketWrite($command ."\r\n"); while (!feof($this->socket)) { $line = trim($this->socketRead()); if (preg_match('/^(\d{3})(?:\s+(.*))?$/', $line, $matches)) { list(, $code, $text) = $matches; if ($code != $expectedCode) { throw new Exception(sprintf('На этапе "%s" ожидался код %s, но сервер вернул %s', $stage, $expectedCode, $line ), 102); } return true; } } throw new Exception('Cервер неожиданно закрыл соединение', 104); } private function closeSocket() { if (is_resource($this->socket)) { fclose($this->socket); } } private function socketWrite($data) { if (!@fwrite($this->socket, $data)) { throw new Exception(sprintf('Не удалось записать данные в сокет: %s. Ошибка: %s', $data, lastPhpErrorMessage('неизвестная ошибка сокета') ), 103); } } private function socketRead() { $data = @fgets($this->socket); if ($data === false) { throw new Exception(sprintf('Не удалось считать данные из сокета. Ошибка: %s', lastPhpErrorMessage('неизвестная ошибка сокета') ), 104); } return $data; } } class Phpmail implements TransportInterface { private $lastError = null; public function __construct(array $options = null) { if ($options) { // Если появятся опции, то прописывать их тут! } } public function name() { return __CLASS__; } public function send(Message $message) { $this->lastError = null; $recipients = $message->getRecipients(', '); $subject = $message->getSubject(); list($headers, $body) = explode("\r\n\r\n" , (string) $message, 2); $result = @mail($recipients, $subject, $body, $headers); if (!$result) { $this->lastError = lastPhpErrorMessage('неизвестная ошибка'); } return $result; } public function getLastError() { return $this->lastError; } } class File implements TransportInterface { private $saveDir = null; private $createDir = TRUE; private $lastMailPath = null; private $lastError = null; public function __construct(array $options = null) { if ($options) { $this->saveDir = isset($options['dir']) ? rtrim($options['dir'], '/') : null; } } public function name() { return __CLASS__; } public function setSaveDir($path) { $this->saveDir = $path; return $this; } public function getSaveDir() { return $this->saveDir; } public function getLastMailPath() { return $this->lastMailPath; } public function send(Message $message) { $this->lastError = null; $filename = $this->saveDir .'/'. uniqid(date('Y-m-d_H:i:s_')) .'.eml'; $this->lastMailPath = $filename; if ($this->saveDir && !file_exists($this->saveDir) && $this->createDir) { if (!@mkdir($this->saveDir, 0755, true)) { $this->lastError = "Не удалось создать папку {$this->saveDir} по причине: ". lastPhpErrorMessage('неизвестно'); return false; } } $result = @file_put_contents($filename, (string) $message); if ($result === false) { $this->lastError = lastPhpErrorMessage('неизвестная ошибка'); } return $result !== false; } public function getLastError() { return $this->lastError; } } /** * Формирует EML-сообщение. Соблюдается правильная структура сообщения при вложении и подключении файлов. * * Порядок составления документа: * ------------------------------ * multipart/mixed Составной документ (вложения + текст) * |- multipart/alternative Сообщение в нескольких форматах (простой текст + html) * | |- text/plain Простой текст * | |- multipart/related Составной документ (html + файлы) * | |- text/html HTML текст * | |- Relate file A Зависимый файл (не показывается во вложениях) * | |- Relate file B Зависимый файл (не показывается во вложениях) * |- Attachemnt A Независимый файл (показывается во вложениях) * |- Attachment B Независимый файл (показывается во вложениях) */ class Message { private $charset = 'UTF-8'; private $subject = null; private $recipients = []; private $copyTo = []; private $replyTo = []; private $hiddenCopy = []; private $content = null; private $senderEmail = null; private $headers = []; private $related = []; private $attachments = []; public function __construct() { $this->headers = new Headers(); $this->content = new Part(); $this->setContentType('text/html; charset='. $this->charset); $this->headers ->set('MIME-Version', '1.0') ->set('Date' , date(DATE_RFC1123)) ; } /** * @return Headers */ public function getHeaders() { return $this->headers; } public function setContentType($type) { $this->content->setContentType($type); return $this; } public function setContentTypeTextPlain() { $this->content->setContentType('text/plain; charset='. $this->charset); return $this; } public function getContentType() { return $this->content->getContentType(); } public function setContent($data) { $this->content->setContent($data); return $this; } public function addContent($data) { $this->content->addContent($data); return $this; } public function setSubject($subject) { $this->subject = $subject; $this->headers->set('Subject', $subject, true); return $this; } public function getSubject() { return $this->subject; } public function addRecipient($mail, $name = null) { if ($name) { $this->recipients[] = "$name <$mail>"; $this->headers->add('To', $this->headers->encode($name) ." <$mail>"); } else { $this->recipients[] = $mail; $this->headers->add('To', $mail); } return $this; } public function addCopyTo($mail, $name = null) { if ($name) { $this->copyTo[] = $this->headers->encode($name) ." <$mail>"; $this->headers->add('Cc', $this->headers->encode($name) ." <$mail>"); } else { $this->copyTo[] = $mail; $this->headers->add('Cc', $mail); } return $this; } public function addReplyTo($mail, $name = null) { if ($name) { $this->replyTo[] = $this->headers->encode($name) ." <$mail>"; } else { $this->replyTo[] = $mail; } $this->headers->set('Reply-To', join(', ', $this->replyTo)); return $this; } public function addHiddenCopy($mail, $name = null) { if ($name) { $this->hiddenCopy[] = $this->headers->encode($name) ." <$mail>"; $this->headers->add('Bcc', $this->headers->encode($name) ." <$mail>"); } else { $this->hiddenCopy[] = $mail; $this->headers->add('Bcc', $mail); } return $this; } public function addRelatedString($data, $id, $contentType = null) { if (is_null($contentType)) { $contentType = finfo_buffer(finfo_open(FILEINFO_MIME), $data); } $part = new Part(); $part->setContentType($contentType) ->setContentDisposition('inline') ->setContentId($id) ->setContent($data) ; $this->related[] = $part; return $this; } public function addRelatedFile($path, $id = null, $contentType = null) { if (is_null($id)) { $id = basename($path); } if (is_null($contentType)) { $contentType = finfo_file(finfo_open(FILEINFO_MIME), $path); } $part = new Part(); $this->related[] = $part; $part->setContentType($contentType) ->setContentDisposition('inline') ->setContentId($id) ->includeContent($path) ; return $this; } public function addAttachmentString($data, $fileName, $contentType = null) { if (is_null($contentType)) { $contentType = finfo_buffer(finfo_open(\FILEINFO_MIME), $data); } $part = new Part(); $this->attachments[] = $part; $part->setContentType($contentType) ->setContentDisposition('attachment; filename="'. $this->headers->encode($fileName) .'"') ->setContent($data) ; return $this; } public function addAttachmentFile($path, $fileName = null, $contentType = null) { if (is_null($fileName)) { $fileName = basename($path); } if (is_null($contentType)) { $contentType = finfo_file(finfo_open(\FILEINFO_MIME), $path); } $part = new Part(); $this->attachments[] = $part; $part->setContentType($contentType) ->setContentDisposition('attachment; filename="'. $this->headers->encode($fileName) .'"') ->includeContent($path) ; return $this; } public function getId() { return $this->headers->getFirst('Message-ID'); } public function getRecipients($separator = null) { $recipients = array_merge($this->recipients, $this->copyTo, $this->hiddenCopy); // Если адреса кривые, можно расскомментировать следующую строку. Там не менее кривая функция, которая исправляет кривые адреса. // $recipients = Functions::mail2array($recipients); if (isset($separator)) { return join($separator, $recipients); } return $recipients; } public function getRecipientsEmailOnly() { return Functions::mail2array($this->getRecipients()); } public function setSenderEmail($email, $name = null) { $this->senderEmail = $email; if ($name) { $this->headers->set('From', $this->headers->encode($name) ." <$email>"); } else { $this->headers->set('From', "<$email>"); } return $this; } public function getSenderEmail() { return $this->senderEmail; } public function __toString() { return $this->toEml(); } public function toEml() { // Объявляем части письма $message = $this->content; if ($this->related) { $related = new MultiPart('related'); $related->addPart($message); foreach ( $this->related as $part ) { $related->addPart($part); } $message = $related; } if ($this->content->isHtml()) { $alternative = new MultiPart('alternative'); $alternativeText = new Part(); $text = preg_replace('/^[\s\S]*]*>/i', '', $this->content->getContent()); $text = preg_replace('/]+href=[\'"]([^>]+)[\'"][^>]*>([^>]*)<\\/a>/', '$2 $1', $text); $text = trim(strip_tags($text)); $alternativeText->setContentType('text/plain; charset='. $this->charset); $alternativeText->setContent($text); $alternative->addPart($alternativeText); $alternative->addPart($message); $message = $alternative; } if ($this->attachments) { $mixed = new MultiPart('mixed'); $mixed->addPart($message); foreach ($this->attachments as $part) { $mixed->addPart($part); } $message = $mixed; } return $this->headers . $message; } } class Headers { private $storage = []; public function set($header, $value, $encodeValue = false) { if ($encodeValue) { $value = $this->encode($value); } $this->storage[$header] = $value; return $this; } public function add($header, $value, $encodeValue = false) { if ( $encodeValue ) { $value = $this->encode($value); } if ( !isset($this->storage[$header]) ) { $this->storage[$header] = $value; } elseif ( is_array($this->storage[$header]) ) { $this->storage[$header][] = $value; } else { $this->storage[$header] = [$this->storage[$header]]; $this->storage[$header][] = $value; } return $this; } public function has($header) { return isset($this->storage[$header]); } public function remove($header) { unset($this->storage[$header]); return $this; } public function getFirstOrArray($header) { if (!$this->has($header)) { return null; } return $this->storage[$header]; } public function getFirst($header) { if (!$this->has($header)) { return null; } if (is_array($this->storage[$header]) ) { return current($this->storage[$header]); } return $this->storage[$header]; } public function getArray($header) { if (!$this->has($header)) { return []; } if (is_array($this->storage[$header])) { return $this->storage[$header]; } return [$this->storage[$header]]; } public function __toString() { $result = ''; foreach ($this->storage as $header => $value) { if (is_array($value)) { foreach ($value as $subValue) { $result .= "$header: $subValue\r\n"; } } else { $result .= "$header: $value\r\n"; } } return $result; } public function encodeNonAscii($str) { return preg_replace_callback('/[[:^ascii:]].*[[:^ascii:]]+/u', function ($str) { return mb_encode_mimeheader($str[0]); }, $str); } public function encode($str) { return mb_encode_mimeheader($str); } } class Part { protected $headers = null; protected $content = null; public function __construct() { $this->headers = new Headers(); } public function isHtml() { return preg_match('~html~i', $this->getContentType()); } public function isAttachmentOrRelation() { return !is_null($this->getContentDisposition()); } public function setHeader($header, $value) { $this->headers->set($header, $value); return $this; } public function setHeaders($headers) { foreach ($headers as $header => $value) { $this->setHeader($header, $value); } return $this; } public function setContentType($type) { $this->headers->set('Content-type', $type); return $this; } public function getContentType() { return $this->headers->getFirst('Content-type'); } public function setContentId($id) { $this->headers->set('Content-ID', "<$id>"); return $this; } public function setContentDisposition($disposition) { $this->headers->set('Content-disposition', $disposition); return $this; } public function getContentDisposition() { return $this->headers->getFirst('Content-disposition'); } public function setContent($content) { $this->content = $content; return $this; } public function addContent($content) { $this->content .= $content; return $this; } public function getContent() { return $this->content; } public function includeContent($path) { $this->content .= file_get_contents($path); return $this; } public function __toString() { $content = $this->encodeContent(); return $this->headers ."\r\n{$content}\r\n"; } private function encodeContent() { $this->headers->set('Content-Transfer-Encoding', 'base64'); return trim(chunk_split(base64_encode((string) $this->getContent()))); } } class MultiPart extends Part { private $boundary = null; private $parts = []; public function __construct($type) { parent::__construct(); $this->setContentType('multipart/'. $type .'; boundary="'. $this->getBoundary() .'"'); } public function addPart(Part $part) { $this->parts[] = $part; return $this; } public function getContent() { $result = ''; foreach ($this->parts as $part) { $result .= '--'. $this->getBoundary() ."\r\n"; $result .= (string) $part ."\r\n"; } $result .= '--'. $this->getBoundary() ."--\r\n\r\n"; return $result; } public function getBoundary() { if (is_null($this->boundary)) { $this->boundary = str_pad(sha1(uniqid()), 46, '-', STR_PAD_LEFT); } return $this->boundary; } public function __toString() { return $this->headers ."\r\n". $this->getContent(); } } class Functions { /** * Преобразование списка адресов * * @param $mails string * @return array */ static public function mail2array($mails) { $result = array(); if (is_array($mails)) { foreach ($mails as $mail) { $array = self::mail2array($mail); if ($array) { $result = array_merge($result, $array); } } } else { $mails = preg_replace([ '/\s*@\s*/', '/\r?\n/', '/\\.(\\W|$)/u', '/[<>]/', ], [ '@', ', ', ',\\1', '' ], $mails); foreach (preg_split('~[ ,;:/]+~', $mails) as $mail) { $mail = trim($mail); if (self::isValidEmail($mail)) { $result[] = $mail; } } } return array_unique($result); } /** * Проверка почтового адреса на валидность * * @param string $address * @return bool */ static protected function isValidEmail($address) { return (bool) preg_match('/^[\w+\._-]+@[\w+\._-]+$/iu', $address); } } class Mailer { private $version = '1.0.3'; private $layer; public function __construct() { $this->layer = new Layer(); } /** * @example * $config = [ * 'defaultFrom' => 'mymail@example.org', * // 'onError' => function($error, $message, $transport) { myErrorLog($error); }, * // 'afterSend' => function($text, $message, $layer) { myMailLog($text); }, * 'transports' => [ * ['file', 'dir' => __DIR__ .'/mails'], * // ['smtp', 'host' => 'smtp.yandex.ru', 'ssl' => true, 'port' => '465', 'login' => '****@yandex.ru', 'password' => '******'], * // ['smtp', 'host' => 'smtp.gmail.com', 'ssl' => true, 'port' => '465', 'login' => '****@gmail.com', 'password' => '******'], * // ['smtp', 'host' => '192.168.0.1', 'timeout' => 30], * ], * ]; * @param array $config */ public function init(array $config = []) { $conf = function($name, $default = null) use (&$config) { return isset($config[$name]) ? $config[$name] : $default;}; $layer = new Layer(); $defaultFrom = $conf('defaultFrom'); $onError = $conf('onError', function($error) { trigger_error($error, \E_USER_WARNING); }); $afterSend = $conf('afterSend'); $transports = $conf('transports', []); // Обратный адрес по умолчанию if ($defaultFrom) { $layer->appendTrigger('beforeSend', function(Message $message) use ($defaultFrom){ if (!$message->getSenderEmail()) { $message->setSenderEmail($defaultFrom); } }); } if ($onError) { $layer->appendTrigger('error', function(Message $message, TransportInterface $transport) use ($onError){ $error = sprintf('Cообщение %s для %s не было доставлено через %s. Ошибка: %s', $message->getId(), $message->getRecipients(', '), $transport->name(), $transport->getLastError() ); call_user_func($onError, $error, $message, $transport); }); } if ($afterSend) { $layer->appendTrigger('afterSend', function(Message $message, Layer $layer) use ($afterSend) { $text = sprintf('Отправлено сообщение %s для %s от %s.%s', $message->getId(), $message->getRecipients(', '), $message->getSenderEmail(), $layer->getErrors() ? ' Ошибки: ' . join(', ', $layer->getErrors()) : '' ); call_user_func($afterSend, $text, $message, $layer); }); } foreach ($transports as $options) { $options = (array) $options; $type = array_shift($options); $layer->appendTranspport( $this->newTransport($type, $options) ); } $this->layer = $layer; } public function newTransport($type, $config = []) { $class = __NAMESPACE__ .'\\'. ucfirst(strtolower($type)); return new $class($config); } public function addTransport(TransportInterface $transport) { $this->layer->appendTranspport($transport); return $this; } public function newHtmlMessage() { return new Message(); } public function newTextMessage() { return $this->newHtmlMessage()->setContentTypeTextPlain(); } public function sendMessage(Message $message) { return $this->layer->send($message); } public function lastErrors() { return $this->layer->getErrors(); } public function version() { return $this->version; } } } namespace { if (!function_exists('Mailer')) { /** * @return \ApMailer\Mailer */ function Mailer() { static $mailer; if (is_null($mailer)) { $mailer = new ApMailer\Mailer(); } return $mailer; }; } }