Глава 140 · Практика
Mail: письмо с сайта, MIME, безопасность заголовков и доставка
Полный контракт mail и исторического ezmlm_hash: от частей сообщения до UTF-8, транспорта, очереди, проверок формы и диагностики.
Зачем сайту почта и где заканчивается работа PHP
Покупатель оформил заказ; сайт должен сообщить номер и следующий шаг. В этой задаче есть две разные части: правильно собрать письмо и передать его почтовой системе. Пользовательская функция не управляет напрямую папкой «Входящие». Даже успешная передача дальше может закончиться задержкой, отказом адресата или попаданием в спам.
- Сайт сохраняет заказ 17
- PHP подготавливает адрес, тему, текст и заголовки
- mail передаёт сообщение локальной программе/MTA либо Windows SMTP
- Почтовая система маршрутизирует письмо; возможны очередь и возврат
- Сервер получателя принимает или отклоняет сообщение; клиент показывает письмо
<?php
declare(strict_types=1);
$to = 'customer@example.invalid';
$subject = 'Order 17 received';
$message = "Order: 17\r\nStatus: received\r\n";
echo $to, "\n", $subject, "\n";
echo str_replace("\r\n", "\n", $message);
Ожидаемый результат
customer@example.invalid
Order 17 received
Order: 17
Status: received
Три переменные — три части сообщения. Никакая сеть здесь не используется. внутри двойных кавычек обозначает пару управляющих символов для переноса почтовой строки.
Встроенная функция и внешний транспорт
На Unix mail обычно передаёт письмо sendmail-совместимой программе. Это может быть sendmail либо wrapper Postfix/qmail и другой почтовой системы. В требованиях сборки PHP указан поиск sendmail в PATH и стандартных системных каталогах; пользователь сборки должен иметь доступ. На рабочем сервере одной функции PHP недостаточно: нужен установленный и настроенный маршрут обработки писем.
Windows имеет встроенный простой SMTP-транспорт, который соединяется с указанным MTA. В нём нет автоматически предоставленного аккаунта, STARTTLS или SMTP AUTH. Если нужен защищённый SMTP провайдера с авторизацией, используют библиотеку/транспорт с такой поддержкой. Настройка sendmail_path переключает на внешнюю программу и в Windows.
Проверьте конфигурацию нужного PHP SAPI и наличие функции через function_exists. Успешный запуск в терминале не доказывает, что веб-процесс использует тот же ini или имеет те же права. Адрес, порт, внешняя программа и аутентификация — разные настройки.
Все настройки Mail
| Настройка | Стандарт / доступ | Назначение |
|---|---|---|
| SMTP | localhost / INI_ALL | Адрес SMTP только для встроенного Windows транспорта. Это не настройка PHPMailer и не маршрут Unix sendmail. |
| smtp_port | 25 / INI_ALL | Порт встроенного Windows SMTP; сам выбор 465 или 587 не добавляет TLS и аутентификацию. |
| sendmail_from | пусто / INI_ALL | Отправитель по умолчанию для прямого Windows SMTP; участвует в обратном пути. Явный корректный From делает назначение видимым. |
| sendmail_path | обычно /usr/sbin/sendmail -t -i / INI_SYSTEM | Программа/совместимый wrapper, принимающий письмо. Работает также на Windows: при заданном пути SMTP/smtp_port/sendmail_from не используются. |
| mail.force_extra_parameters | пусто / INI_SYSTEM | Административно заменяет пятый аргумент mail, а не дописывает его. Возможна настройка php_value в httpd.conf, но такой способ не рекомендуется документацией. |
| mail.add_x_header | 0 / INI_SYSTEM|INI_PERDIR | Добавляет X-PHP-Originating-Script с UID и именем файла. Может раскрывать внутренние сведения; не механизм подтверждения подлинности. Режим доступа уточнён по PHP8.5.11. |
| mail.log | пусто / INI_SYSTEM|INI_PERDIR | Журнал вызовов: путь скрипта, строка, адрес To и заголовки. Ограничивайте доступ и срок хранения: там могут быть персональные данные. |
| mail.mixed_lf_and_crlf | 0 / INI_SYSTEM|INI_PERDIR | С PHP8.2.4 совместимость с MTA, не принимающим корректный CRLF; возвращает старое смешанное поведение. Применяется по установленной причине, не как универсальное лечение доставки. |
| mail.cr_lf_mode | crlf / INI_SYSTEM|INI_PERDIR | Есть в проверенном PHP8.5.11: crlf, lf, mixed либо os. Управляет разделителями при передаче внешнему sendmail; не включает TLS. lf также преобразует CRLF тела в LF; mixed оставляет тело как передано; os выбирает разделители заголовков по ОС. При crlf сохраняется совместимость с включённым старым mail.mixed_lf_and_crlf. Настройку нужно согласовать с конкретным MTA; не считать доступной во всех старых PHP. |
INI_SYSTEM меняется в конфигурации запуска, INI_PERDIR — на разрешённом уровне каталога/сервера, INI_ALL допускает runtime-изменение. Имя SMTP пишется именно так; оно не добавляет TLS одним изменением порта. Не публикуйте phpinfo и почтовые журналы с внутренними путями для посетителей.
Конверт, заголовки и тело — три разных уровня
| Элемент | Что решает | Типичная ошибка |
|---|---|---|
| Envelope sender | Куда отправлять служебные возвраты | Считать любой Reply-To адресом возврата. |
| From | Кто указан автором письма | Подставить произвольный домен посетителя от имени сайта. |
| Reply-To | Куда почтовый клиент предложит ответить | Разрешить CR/LF из поля формы и внедрение других заголовков. |
| To / Cc / Bcc | Получатели и видимость адресов | Показать адреса всех подписчиков в To. |
| Subject | Тема | Передать кириллицу без согласованного MIME-представления. |
| Content-Type / Transfer-Encoding | Формат/кодировка тела | Дописать текст/html в тело вместо заголовка. |
Сайт отправляет письмо от контролируемого проектом адреса. Адрес посетителя после проверки можно использовать как Reply-To, если это соответствует назначению формы. HTML-экранирование не предотвращает внедрение почтового заголовка: контекст другой. В шаблоне HTML-тела htmlspecialchars защищает вставляемый текст, но не становится валидатором email.
Русский текст, MIME и длина строки
Кодировка темы и кодировка тела независимы. Для темы используется encoded-word RFC2047; для тела указывают charset и Content-Transfer-Encoding. Base64 удобен для точной транспортной передачи UTF-8, но увеличивает размер. Для сложного HTML, вложений, multipart/alternative и международных адресов разумнее библиотека, которая формирует MIME целиком.
<?php
declare(strict_types=1);
$subject = 'Заказ 17 принят';
$encodedSubject = mb_encode_mimeheader($subject, 'UTF-8', 'B', "\r\n");
$plain = "Спасибо за заказ.\r\n";
$encodedBody = chunk_split(base64_encode($plain), 76, "\r\n");
var_dump(mb_decode_mimeheader($encodedSubject) === $subject);
var_dump(base64_decode($encodedBody, true) === $plain);
Ожидаемый результат
bool(true)
bool(true)
Проверена подготовка байтов, без отправки. В письме также нужны заголовки Content-Type и Content-Transfer-Encoding, как в карточке mail.
Официальный простой пример mail предлагает переносить строки примерно на70 символов. RFC5322 рекомендует до78 и ограничивает строку998 символами без CRLF; у конкретного MIME-кодирования свои правила (Base64 — до76). Это не повод применять wordwrap вслепую к UTF-8, URL, заголовку или готовому HTML: можно разорвать данные. Генератор MIME выбирает переносы по типу содержимого.
С PHP8 обычные заголовки формируются с CRLF. Старые qmail-совместимые цепочки иногда сами преобразуют LF и получают двойной CR; для подтверждённой несовместимости есть mail.mixed_lf_and_crlf. Не исправляйте все письма глобальной заменой переносов «на удачу». В Windows PHP8.5.11 сам удваивает точки после в SMTP DATA; старый совет повторить str_replace(" .", " ..", ...) способен удвоить их лишний раз. Этот край проверяйте на выбранном транспорте, особенно при произвольном сыром теле.
Как не превратить форму обратной связи в почтовый ретранслятор
Пусть HTML-поле называется reply_email. После обычного POST-отправления формы PHP получает его в $_POST["reply_email"]. Клиент может прислать вместо строки массив, огромный текст либо строку с переводами строк. Сначала метод, CSRF/разрешённость действия и тип поля, затем ограничения и формат; получатель и отправитель задаются конфигурацией сайта.
<?php
declare(strict_types=1);
function checkedReplyEmail(mixed $raw): string {
if (!is_string($raw) || strlen($raw) > 254) throw new InvalidArgumentException('Нужен короткий адрес');
if (str_contains($raw, "\r") || str_contains($raw, "\n") || str_contains($raw, "\0")) {
throw new InvalidArgumentException('Управляющие символы запрещены');
}
$email = trim($raw);
if (filter_var($email, FILTER_VALIDATE_EMAIL) === false) throw new InvalidArgumentException('Некорректный формат');
return $email;
}
foreach (['buyer@example.invalid', ['unexpected'], "buyer@example.invalid\nBcc: other@example.invalid"] as $raw) {
try { echo checkedReplyEmail($raw), "\n"; }
catch (InvalidArgumentException $error) { echo "Отклонено\n"; }
}
Ожидаемый результат
buyer@example.invalid
Отклонено
Отклонено
Это намеренно ограниченная политика одного ASCII email. Она не подтверждает владение ящиком и не поддерживает автоматически весь диапазон международных адресов. Полный обработчик формы дополняет её проверкой метода, CSRF и ограничением частоты.
Связанный полный HTML и обработка полей находятся в разделе форм, типы суперглобальных данных — в суперглобальных массивах. Ограничивайте размер тела, частоту и количество сообщений. Сохранение заявки должно иметь собственный результат; временная ошибка почты не должна молча уничтожать уже принятую заявку.
HTML-уведомление: содержимое из данных, не из произвольной разметки
<?php
declare(strict_types=1);
$productName = '<Ключ> & комплект';
$safeName = htmlspecialchars($productName, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
$html = '<p>Товар: ' . $safeName . '</p>';
echo $html, "\n";
Ожидаемый результат
<p>Товар: <Ключ> & комплект</p>
Это подготовка HTML-тела. При отправке указывают text/html; для клиента без HTML добавляют plain-text альтернативу через multipart. Кнопка в письме не должна сама выполнять опасное действие простым GET.
Вложения, встроенные изображения, boundary и переносы заголовков требуют согласованного MIME. Исторический PHP Manual ссылается на PEAR::Mail, Mail_Mime и Mail_Queue; их роль сохраняем как указатель старых решений. Современный проект может выбрать PHPMailer либо Symfony Mailer через Composer. Они не заменяют разрешение отправителя и настройку доставляемости.
Почему true ещё не означает доставку
| Наблюдение | Что исследовать |
|---|---|
| Функции нет / TypeError / ValueError | Версию, конфигурацию и собственные аргументы до транспорта. |
| false и предупреждение | Путь sendmail, разрешения, доступность локального SMTP, его ответ и журнал. |
| true, но письма нет | Очередь и журнал MTA, ответ получателя, возврат, спам-папку и политику домена отправителя. |
| Письмо пришло дважды | Повторный вызов, retry после неопределённого результата, отсутствие идентификатора задания. |
| Кириллица испорчена | Исходные байты, charset, MIME-тему и transport encoding. |
При PHP8.5 sendmail-сбой и неожиданное завершение внешнего процесса диагностируются полнее. Предупреждение не надо подавлять @ и выдавать посетителю «всё отправлено». В пользовательском ответе показывают понятный статус, в защищённом журнале — технические сведения без токенов и полного личного текста. Конкретный SMTP/MTA журнал — источник следующего шага.
После принятия сообщения возможна отдельная ошибка доставки. Если нужно отслеживание, храните ID задания, принятый статус транспорта и события доставки от выбранной системы отдельно. Открытие письма тоже не доказывается одним результатом mail.
Большая рассылка, очередь и повторные попытки
mail в цикле для тысяч адресатов создаёт дорогое количество отдельных передач: Windows открывает SMTP-соединение для каждого письма, Unix обычно запускает программу. Задержка HTTP-ответа и таймауты становятся проблемой раньше, чем синтаксис цикла. Записывайте задания в очередь и отправляйте ограниченным worker с согласованным транспортом.
Повторять нужно с пониманием результата. Если соединение оборвалось после принятия, автоматический немедленный retry может создать дубль. Сохраняйте устойчивый ID события/уведомления и политику повторов; учитывайте лимиты провайдера и отказ подписчика. sleep и задержки сами по себе не дают надёжную очередь и гарантию доставки.
Все функции категории Mail и связанные инструменты
| Инструмент | Роль |
|---|---|
| Нынешняя встроенная передача письма транспорту. | |
| ezmlm_hash | Исторический API EZMLM, удалённый в PHP8. |
| mb_send_mail | Отдельная функция mbstring для кодированного письма. |
| iconv_mime_encode | Подготовка MIME-заголовка. |
| ob_start и шаблон | Получение HTML из шаблона в строку. |
Mail не предоставляет своих классов, ресурсов и набора специальных констант; основные настройки перечислены выше. IMAP/Mailparse — отдельные категории с иными задачами, а не скрытые режимы mail.
Практика: принять заявку даже при временном сбое почты
Спроектируйте три состояния: заявка сохранена, уведомление ожидает отправки, транспорт принял уведомление. Не превращайте отсутствие письма в отсутствие заявки. Начните с модели состояния, затем свяжите её с базой и worker.
<?php
declare(strict_types=1);
$request = ['id' => 17, 'saved' => true];
$notification = ['request_id' => 17, 'status' => 'pending', 'attempts' => 0];
$transportAccepted = false; // Модель отказа, сеть не вызывается.
$notification['attempts']++;
if ($transportAccepted) $notification['status'] = 'accepted';
echo $request['saved'] ? "Заявка сохранена\n" : "Заявка не сохранена\n";
echo $notification['status'], ':', $notification['attempts'], "\n";
Ожидаемый результат
Заявка сохранена
pending:1
Это модель перехода, а не готовая надёжная очередь: в рабочем проекте состояния сохраняются устойчиво, worker блокирует конкретное задание и ограничивает повторы. Полезный результат для пользователя не зависит от синхронного успеха SMTP.
Функции и методы этой темы
Откройте карточку, чтобы увидеть договор функции, примеры, ограничения и связанные инструменты.
mailПередать простое сообщение настроенному почтовому транспорту.
mail(string $to, string $subject, string $message, array|string $additional_headers = [], string $additional_params = ""): boolmail передаёт подготовленное письмо почтовому транспорту. Она не регистрирует SMTP-аккаунт, не добавляет самостоятельно MIME-вложение и не узнаёт, прочитал ли пользователь письмо. У функции полезная узкая роль: отдать простое сообщение уже настроенной почтовой системе.
| Аргумент | Что передавать и зачем |
|---|---|
| to | Адрес получателя либо список через запятую в допустимом формате. Для прямого Windows SMTP безопаснее простые адреса без отображаемого имени: формат Name может разбираться иначе, чем Unix MTA. |
| subject | Тема; для не-ASCII нужна подходящая MIME-кодировка RFC2047. Нельзя считать сырую строку из формы готовым безопасным заголовком. |
| message | Тело: plain text либо должным образом оформленный MIME. Переносы CRLF. Не содержит заголовков From/Subject вместо четвёртого аргумента. |
| additional_headers | Ассоциативный массив имя => значение (с7.2) или собранная строка заголовков с CRLF. Задайте From. Для массива To и Subject запрещены: используйте первые два аргумента. |
| additional_params | Дополнительные параметры внешней sendmail-программы, например доверенный фиксированный -f для envelope sender. Не произвольные данные пользователя и не SMTP-пароль. |
| return | true — транспорт принял сообщение для доставки; false — не принял. Последующая доставка, спам-фильтр и возврат письма происходят отдельно. |
| Ошибки | Неправильные типы/нулевые байты/запрещённые заголовки могут приводить к TypeError/ValueError; сбои транспорта — предупреждение и false. В8.5 улучшено обнаружение ошибки и неожиданного завершения sendmail. |
<?php
declare(strict_types=1);
$accepted = mail(
'customer@example.invalid',
'Order 17 received',
"Order: 17\r\nStatus: received\r\n",
['From' => 'shop@example.invalid']
);
echo $accepted ? "Принято для доставки\n" : "Не принято\n";
Ожидаемый результат
Принято для доставки
Ожидаемый результат относится к транспорту, который согласился принять тестовое сообщение. Для собственного сервера нужны принадлежащие проекту адреса и настроенный маршрут; это не доказательство попадания в Inbox.
Теперь те же части можно подготовить по отдельности: адрес, тема, тело и массив заголовков остаются обычными PHP-значениями. MIME описывает представление текста. Base64 здесь — транспортное кодирование, а не шифрование или защита секретов.
<?php
declare(strict_types=1);
$subject = mb_encode_mimeheader('Заказ 17 принят', 'UTF-8', 'B', "\r\n");
$plain = "Ваш заказ 17 принят.\r\nМы сообщим об отправке.\r\n";
$headers = [
'From' => 'shop@example.invalid',
'MIME-Version' => '1.0',
'Content-Type' => 'text/plain; charset=UTF-8',
'Content-Transfer-Encoding' => 'base64',
];
$body = chunk_split(base64_encode($plain), 76, "\r\n");
$accepted = mail('customer@example.invalid', $subject, $body, $headers);
echo $accepted ? "Принято для доставки\n" : "Не принято\n";
Ожидаемый результат
Принято для доставки
Предусловие: mbstring и настроенный транспорт. Тема закодирована отдельно от тела; строки Base64 ограничены 76 символами. Этот пример не конструирует вложения или multipart-письмо.
<?php
declare(strict_types=1);
$headers = "From: shop@example.invalid\r\n"
. "Reply-To: support@example.invalid\r\n"
. "Content-Type: text/plain; charset=UTF-8";
$accepted = mail('customer@example.invalid', 'Ticket received', "Ticket: 17\r\n", $headers);
echo $accepted ? "Принято для доставки\n" : "Не принято\n";
Ожидаемый результат
Принято для доставки
Каждый заголовок отделён CRLF; пустая строка внутри заголовков преждевременно закончила бы их блок. В новом простом коде ассоциативный массив легче проверить.
<?php
declare(strict_types=1);
// Только сервер с согласованным sendmail-совместимым транспортом.
$accepted = mail(
'customer@example.invalid', 'Order receipt', "Order: 17\r\n",
['From' => 'shop@example.invalid'],
'-fbounces@example.invalid'
);
echo $accepted ? "Принято для доставки\n" : "Не принято\n";
Ожидаемый результат
Принято для доставки
Пятый аргумент — строка параметров команды. В реальном проекте домен обратного пути должен принадлежать отправителю, а MTA должен разрешать этому системному пользователю выбранный envelope sender. sendmail trusted-users — серверная конфигурация, не настройка посетителя.
Встроенное escapeshellcmd для пятого аргумента не запрещает дополнительные флаги. Поэтому передать туда -f плюс строку посетителя опасно даже при экранировании shell. Некоторые синтаксически допустимые email-символы несовместимы с этой командной формой; для сложных адресов выбирают полноценный почтовый транспорт/библиотеку.
<?php
declare(strict_types=1);
try {
mail('customer@example.invalid', 'Ticket', 'Text', [
'From' => 'shop@example.invalid',
'Reply-To' => "buyer@example.invalid\r\nBcc: other@example.invalid",
]);
} catch (ValueError $error) {
echo "Недопустимые символы заголовка отклонены\n";
}
Ожидаемый результат
Недопустимые символы заголовка отклонены
PHP8.5 отвергает эту форму до обращения к транспорту. Это демонстрация встроенной проверки, а не причина отказаться от собственной проверки входа.
Не добавляйте дублирующие To/Subject в массив дополнительных заголовков: текущий PHP их отвергает. From/Reply-To/Cc/Bcc имеют разные назначения. Bcc не должен появляться в доставленном списке видимых адресатов; поведение конечной упаковки зависит от транспорта, поэтому массовую рассылку проверяют отдельным почтовым инструментом, а не публикуют все адреса в to.
Рядом: mb_send_mail — кодирование средствами mbstring, iconv_mime_encode, сборка шаблона письма, безопасная приёмка формы.
ezmlm_hashИсторическое распределение email по корзинам EZMLM; удалено из PHP8.
ezmlm_hash(string $addr): int — только PHP4.0.2–7.4ezmlm_hash обслуживала конкретный старый формат распределения адресов EZMLM/MySQL по корзинам. Это не отправка письма, не проверка email и не криптографический хеш. Результат находился в диапазоне 0…52; разные адреса легко попадают в одну корзину.
| Часть | Исторический контракт |
|---|---|
| addr | Строка адреса, обрабатываемая старым алгоритмом EZMLM. |
| return | Целое число корзины. Не уникальный идентификатор подписчика. |
| Версии | Доступна с PHP4.0.2, deprecated в7.4, удалена в8.0. Строка changelog на странице с7.2 противоречит реализации: в7.2/7.3 регистрация обычная, в7.4 — deprecated. |
<?php
declare(strict_types=1);
echo function_exists('ezmlm_hash') ? "Есть старый API\n" : "Нужна миграция\n";
Ожидаемый результат
Нужна миграция
Пример для PHP8.5. Отсутствие функции не исправляется случайной заменой на hash: если старое хранилище рассчитывает на EZMLM-корзины, другой алгоритм нарушит совместимость.
<?php
declare(strict_types=1);
$db = new PDO('sqlite::memory:');
$db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$db->exec('CREATE TABLE subscribers (id INTEGER PRIMARY KEY, email TEXT NOT NULL UNIQUE)');
$insert = $db->prepare('INSERT INTO subscribers(email) VALUES (:email)');
$insert->execute(['email' => 'reader@example.invalid']);
echo $db->query('SELECT email FROM subscribers')->fetchColumn(), "\n";
Ожидаемый результат
reader@example.invalid
Это современный вариант для НОВОГО списка, а не замена алгоритма существующего EZMLM. SQL-параметры передают отдельно от текста запроса; UNIQUE закрепляет выбранную схему уникальности. Подписка требует подтверждения согласия и адреса на уровне приложения.
<?php
declare(strict_types=1);
// Только старый PHP с ещё существующей функцией; не выполнять на PHP8.
$address = 'reader@example.invalid';
$bucket = ezmlm_hash($address);
echo $bucket;
Возвращаемое число зависит от адреса и старого алгоритма. Фрагмент оставлен для понимания legacy-кода; не заявляется запуск удалённого API в PHP8.
Если действительно мигрируете старый EZMLM, сохраните его правило корзин и сравните со старой системой на наборе известных адресов. Не считайте совпадение одной строки доказательством совместимости и не используйте этот алгоритм для паролей. Связанные главы: назначение хеш-функций, параметризованные запросы.