Форма обратной связи с отправкой на почту — это HTML-форма, которая передает введенные данные на сервер, и серверный скрипт, который проверяет их и отправляет письмо на ваш адрес. Сам HTML письма не отправляет: тег <form> только собирает поля и делает POST-запрос по адресу из action. Письмо формирует обработчик (ниже — на PHP), а доставляет почтовый транспорт: локальный sendmail или SMTP-сервер.
Содержание
- Три звена: форма, обработчик, транспорт
- Какой способ выбрать
- Рабочий пример: HTML-форма и PHP-обработчик
- Как проверить без настоящей почты
- Разбор обработчика
- Ошибка: инъекция заголовков письма
- Отправка через SMTP (PHPMailer)
- Почему письма уходят в спам
- Защита от спама
- Что еще нужно в production
- Если не получилось
- Выводы
- Где применяется / связь с практикой
- FAQ
Ниже — полный рабочий пример «форма + обработчик», проверка без настоящей почты, защита от спама и от подмены заголовков письма, вариант через SMTP и способы обойтись без своего сервера. Это учебная демонстрация: что в ней упрощено и что обязательно добавить на рабочем сайте, собрано в таблицу в разделе про production. Код проверен на PHP 8.4.25 и 8.3 (docker-образы php:8.4-cli, php:8.3-cli) 25.09.2026.
Три звена: форма, обработчик, транспорт
| Звено | Что делает | Где живет |
|---|---|---|
| HTML-форма | Показывает поля, отправляет POST на action |
Браузер посетителя |
| Обработчик | Проверяет поля, собирает письмо, отвечает посетителю | Ваш сервер (PHP, Node.js, Python) |
| Почтовый транспорт | Доставляет письмо адресату | sendmail на сервере или внешний SMTP |
Еще два термина, которые путают. From — от чьего имени отправлено письмо; это должен быть адрес на домене вашего сайта. Reply-To — куда уйдет ответ, когда менеджер нажмет «Ответить»; сюда ставят email посетителя. Если поставить адрес посетителя в From, почтовые сервисы с высокой вероятностью отправят письмо в спам или отклонят его.
Какой способ выбрать
| Способ | Нужен свой сервер | Плюсы | Минусы |
|---|---|---|---|
Ссылка mailto: |
Нет | Одна строка HTML | Открывает почтовую программу посетителя; без нее не работает |
| Конструктор форм (например, Яндекс Формы) | Нет | Встраивается кодом, есть уведомления на почту | Данные хранятся у сервиса, дизайн ограничен |
PHP mail() |
Да, с настроенным sendmail | Без библиотек | На многих хостингах отключен или попадает в спам |
| PHP + SMTP (PHPMailer) | Да | Авторизация на почтовом сервере, лучше доставляемость | Нужна библиотека и учетка SMTP |
Если сайт статический и писем мало — хватит mailto: или конструктора. Если нужна своя логика (поля, проверки, запись в CRM) — свой обработчик.
Рабочий пример: HTML-форма и PHP-обработчик
Структура проекта. Публично доступна только папка public/, настройки и служебные файлы лежат в var/ — вне корня сайта:
project/
public/index.html форма
public/send.php обработчик
public/thanks.html страница «спасибо»
var/config.php адреса почты
var/rate/ отметки времени для лимита
Форма public/index.html:
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<title>Обратная связь</title>
</head>
<body>
<form action="/send.php" method="post">
<label>Имя <input name="name" required maxlength="100"></label>
<label>Email <input name="email" type="email" required maxlength="254"></label>
<label>Сообщение <textarea name="message" required maxlength="5000"></textarea></label>
<!-- ловушка для ботов: человек это поле не видит и не заполняет -->
<input name="website" tabindex="-1" autocomplete="off" hidden>
<button type="submit">Отправить</button>
</form>
</body>
</html>
Атрибуты required, type="email" и maxlength — подсказка для браузера, не защита: бот отправляет POST напрямую и их не видит. Разметку и стили формы подробно разбирает статья Форма обратной связи на HTML и CSS, здесь фокус на сервере.
Настройки var/config.php:
<?php
return [
'to' => 'manager@example.com', // куда приходят заявки
'from' => 'no-reply@example.com', // адрес на домене вашего сайта
// для варианта с SMTP ниже: smtp_host, smtp_user, smtp_pass
];
Обработчик public/send.php:
<?php
declare(strict_types=1);
$config = require __DIR__ . '/../var/config.php';
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
header('Allow: POST');
exit;
}
// Бот заполнил скрытое поле: делаем вид, что все хорошо, и ничего не шлем
if (($_POST['website'] ?? '') !== '') {
header('Location: /thanks.html', true, 303);
exit;
}
function field(string $key, int $max): ?string
{
$value = $_POST[$key] ?? null;
if (!is_string($value)) {
return null; // поля нет или пришел массив name[]=...
}
$value = trim($value);
if (!mb_check_encoding($value, 'UTF-8')) {
return null; // битые байты вместо UTF-8
}
return ($value === '' || mb_strlen($value) > $max) ? null : $value;
}
$name = field('name', 100);
$email = field('email', 254);
$message = field('message', 5000);
$errors = [];
if ($name === null || preg_match('/[\x00-\x1F\x7F]/', $name)) {
$errors[] = 'Имя: от 1 до 100 символов, одной строкой';
}
if ($email === null || filter_var($email, FILTER_VALIDATE_EMAIL) === false) {
$errors[] = 'Email указан с ошибкой';
}
if ($message === null) {
$errors[] = 'Сообщение: от 1 до 5000 символов';
}
if ($errors) {
http_response_code(422);
header('Content-Type: text/html; charset=UTF-8');
foreach ($errors as $e) {
echo '<p>', htmlspecialchars($e), '</p>';
}
exit('<p><a href="/">Вернуться к форме</a></p>');
}
// Учебный лимит: не чаще одного письма в минуту с одного REMOTE_ADDR.
// flock делает проверку и запись одной атомарной операцией, но только для
// процессов одного сервера с локальным диском. Несколько серверов, прокси
// или CDN - см. таблицу «в примере / в production» ниже.
$rateDir = __DIR__ . '/../var/rate';
$fh = is_dir($rateDir)
? @fopen($rateDir . '/' . sha1($_SERVER['REMOTE_ADDR']), 'c+')
: false;
// Лимит не должен молча отключаться: нет папки или прав - письмо не шлем
if ($fh === false || !flock($fh, LOCK_EX)) {
error_log('contact form: rate dir is not writable');
http_response_code(500);
exit('Не удалось отправить, попробуйте позже');
}
$wait = 60 - (time() - (int) stream_get_contents($fh));
if ($wait > 0) {
http_response_code(429);
header('Retry-After: ' . $wait);
exit('Слишком часто, попробуйте через минуту');
}
if (!ftruncate($fh, 0) || !rewind($fh) || fwrite($fh, (string) time()) === false) {
error_log('contact form: rate file is not writable');
http_response_code(500);
exit('Не удалось отправить, попробуйте позже');
}
fflush($fh);
flock($fh, LOCK_UN);
fclose($fh);
$sent = mail(
$config['to'],
mb_encode_mimeheader('Заявка с сайта от ' . $name, 'UTF-8'),
"Имя: $name\nEmail: $email\n\n$message\n",
[
'From' => $config['from'],
'Reply-To' => $email,
'MIME-Version' => '1.0',
'Content-Type' => 'text/plain; charset=UTF-8',
],
'-f' . $config['from']
);
if (!$sent) {
error_log('contact form: mail() returned false');
http_response_code(500);
exit('Не удалось отправить, попробуйте позже');
}
header('Location: /thanks.html', true, 303);
thanks.html — обычная страница с текстом «Заявка принята. Если ответ не придет, свяжитесь с нами другим способом». Писать «Сообщение отправлено» не стоит: true от mail() значит только, что локальный sendmail принял письмо к отправке, а не что оно дошло до адресата. Оно может застрять в очереди, попасть в спам или быть отклонено. Поэтому на рабочем сайте заявку сначала сохраняют (база, CRM), а уже потом отправляют письмо-уведомление.
Про лимит. Первая версия этого примера проверяла отметку через is_file() и ставила ее через touch(): между проверкой и записью есть окно, и параллельные запросы проходили оба. Здесь файл открывается с эксклюзивной блокировкой flock, поэтому второй параллельный запрос ждет первого и видит уже записанное время. Это все равно учебный последовательный лимит одного сервера: на нескольких серверах или за прокси нужен атомарный счетчик в общем хранилище (например, INCR с TTL в Redis) либо ограничение на входном прокси с учетом доверенного IP клиента.
Как проверить без настоящей почты
На локальной машине sendmail обычно нет, и mail() вернет false. Для проверки подменим sendmail скриптом, который пишет письмо в файл. Команды — из корня проекта, нужен PHP 8.x в системе. printf %q (bash или zsh) экранирует пробелы в пути: кавычки из значения sendmail_path PHP при разборе убирает. Флаги -t -i — те же, что у настоящего sendmail по умолчанию, а папку var/rate создаем заранее: без нее обработчик отвечает 500 и не шлет письмо, а не работает без лимита. Переменная PHP_CLI_SERVER_WORKERS запускает несколько процессов встроенного сервера (Linux и macOS), чтобы параллельные запросы действительно шли одновременно:
mkdir -p var/rate
printf '#!/bin/sh\n{ echo "ARGS: $*"; cat; } >> "$(dirname "$0")/var/outbox.eml"\n' > fake-sendmail.sh
chmod +x fake-sendmail.sh
PHP_CLI_SERVER_WORKERS=4 php -d sendmail_path="$(printf %q "$PWD/fake-sendmail.sh") -t -i" -S localhost:8080 -t public
Во втором терминале отправим форму два раза подряд:
curl -i -s http://localhost:8080/send.php \
--data-urlencode 'name=Анна' \
--data-urlencode 'email=anna@example.org' \
--data-urlencode 'message=Здравствуйте, нужна консультация' | head -1
curl -s -o /dev/null -w '%{http_code}\n' http://localhost:8080/send.php \
-d name=Bot -d email=bot@example.org -d message=spam
cat var/outbox.eml
Первый запрос получил редирект на «спасибо», второй уперся в лимит, а в файле — готовое письмо:
HTTP/1.1 303 See Other
429
ARGS: -t -i -fno-reply@example.com
To: manager@example.com
Subject: =?UTF-8?B?0JfQsNGP0LLQutCwINGBINGB0LDQudGC0LAg0L7RgiDQkNC90L3QsA==?=
From: no-reply@example.com
Reply-To: anna@example.org
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Имя: Анна
Email: anna@example.org
Здравствуйте, нужна консультация
Проверка лимита под нагрузкой: 20 одновременных запросов с одного адреса (после паузы больше минуты или с пустой var/rate) дали один ответ 303 и девятнадцать 429, в outbox.eml — одно письмо. Чтобы увидеть гонку, в прежний вариант с is_file()/touch() вставили паузу 0,2 с между проверкой и записью (имитация медленного диска): он пропустил 5 писем из 20 запросов, вариант с flock с той же паузой — одно. Без паузы окно узкое и гонка ловится редко, но на загруженном сервере оно шире.
Тема закодирована по MIME: без mb_encode_mimeheader русская тема в части почтовых программ превращается в «кракозябры». Злые входы тоже проверены: name[]=x (массив), перевод строки или управляющий символ в имени, битые байты вместо UTF-8, email с \r\nBcc:, пустое сообщение и сообщение на 5001 символ получают 422 со списком ошибок, GET — 405, заполненная ловушка — 303 без письма.
Разбор обработчика
- POST и 303. После успешной отправки браузер уходит на
thanks.htmlпо GET, и обновление страницы не отправит форму повторно (шаблон Post/Redirect/Get). - Проверка типа.
$_POST['name']может оказаться массивом, если прислатьname[]=.... Функцияfield()отбрасывает все, что не строка, проверяет, что это корректный UTF-8, и ограничивает длину в символах (mb_strlen), а не в байтах. В имени запрещены все управляющие символы, не только перевод строки. - Три разных действия. Валидация входа (длина, формат email), кодирование вывода (
htmlspecialcharsдля HTML-ответа,mb_encode_mimeheaderдля темы) и запрет перевода строки в заголовках решают разные задачи и друг друга не заменяют. - Секреты вне корня сайта.
var/config.phpне отдается по URL. Встроенный сервер PHP на запрос/var/config.phpвернул страницу формы, а не конфиг (в выводе 0 совпадений с адресом менеджера); на nginx/Apache проверьте тем же запросом, что ответ не содержит ваших настроек.
Ошибка: инъекция заголовков письма
Самая частая уязвимость самописных форм — склеить значение из формы со строкой заголовков.
<?php
$email = "anna@example.org\r\nBcc: victim@example.net"; // пришло из $_POST
// Неверно: значение из формы склеено в строку заголовков
mail('manager@example.com', 'Заявка', 'Текст', "From: no-reply@example.com\r\nReply-To: $email");
Результат: в письме появился лишний заголовок, и копия ушла на чужой адрес. Так форму превращают в рассылку спама с вашего домена.
From: no-reply@example.com
Reply-To: anna@example.org
Bcc: victim@example.net
Исправление — двойное. Во-первых, filter_var($email, FILTER_VALIDATE_EMAIL) для такой строки возвращает false. Во-вторых, если передать заголовки массивом, PHP 8.4 сам отказывается отправлять:
ValueError: Header "Reply-To" contains CRLF characters that are used as a line separator and are not allowed in the header
Имя попадает в тему письма, поэтому в обработчике перевод строки в имени тоже запрещен.
Отправка через SMTP (PHPMailer)
mail() зависит от sendmail на сервере; на виртуальном хостинге его часто ограничивают, а письма без авторизации хуже доходят. Надежнее отправлять через SMTP почтового ящика на вашем домене. Библиотека PHPMailer ставится через Composer: composer require phpmailer/phpmailer. Замена блока с mail():
use PHPMailer\PHPMailer\PHPMailer;
use PHPMailer\PHPMailer\Exception;
require __DIR__ . '/../vendor/autoload.php';
$mail = new PHPMailer(true); // true = ошибки бросаются исключением
try {
$mail->isSMTP();
$mail->Host = $config['smtp_host'];
$mail->SMTPAuth = true;
$mail->Username = $config['smtp_user'];
$mail->Password = $config['smtp_pass']; // пароль приложения, хранится в var/
$mail->SMTPSecure = PHPMailer::ENCRYPTION_SMTPS; // TLS сразу, обычно порт 465
$mail->Port = 465;
$mail->CharSet = 'UTF-8';
$mail->setFrom($config['from']);
$mail->addAddress($config['to']);
$mail->addReplyTo($email, $name);
$mail->Subject = 'Заявка с сайта от ' . $name;
$mail->Body = "Имя: $name\nEmail: $email\n\n$message\n";
$mail->send();
} catch (Exception $e) {
error_log('contact form: ' . $mail->ErrorInfo);
http_response_code(500);
exit('Не удалось отправить, попробуйте позже');
}
header('Location: /thanks.html', true, 303);
Порт и режим шифрования зависят от почтового провайдера: 465 с ENCRYPTION_SMTPS или 587 с ENCRYPTION_STARTTLS. Для почты с двухфакторной защитой обычно нужен отдельный пароль приложения, а не пароль от ящика.
Почему письма уходят в спам
Почтовые сервисы проверяют, имеет ли сервер право отправлять от имени домена из From. Для этого у домена настраивают три DNS-записи: SPF (какие серверы могут отправлять), DKIM (подпись письма) и DMARC (что делать с письмами, не прошедшими проверки). Отправка от адреса бесплатной почты через ваш сервер эти проверки не пройдет — поэтому From всегда на своем домене, а адрес посетителя только в Reply-To.
Защита от спама
Ловушка-поле и учебный лимит по IP из примера отсекают простых ботов, но не всех. Следующие уровни: проверка времени заполнения (форма, отправленная через секунду после загрузки, почти наверняка от бота), капча (Yandex SmartCaptcha, Google reCAPTCHA) с обязательной проверкой токена на сервере, а не только в браузере. Лимит в примере учебный: за прокси или CDN в REMOTE_ADDR окажется адрес прокси, и лимит станет общим для всех посетителей. Реальный IP клиента берут из заголовка (X-Forwarded-For) только если запрос пришел от вашего доверенного прокси: иначе заголовок подделывает сам бот.
Что еще нужно в production
| Что | В примере | Обязательно в production |
|---|---|---|
| Лимит частоты | flock на файле, один сервер, REMOTE_ADDR |
атомарный счетчик в общем хранилище или лимит на входном прокси; IP клиента — с учетом доверенного прокси |
| Учет заявки | только письмо | сохранить заявку в базе или CRM до отправки, отслеживать ошибки доставки |
| Ответ посетителю | «Заявка принята», без обещания доставки | то же, плюс запасной контакт |
| CSRF | нет | токен в сессии или проверка Origin/Sec-Fetch-Site, если форма работает в сессии пользователя |
| Транспорт | mail() и локальный sendmail |
SMTP с авторизацией, SPF/DKIM/DMARC для домена |
| Персональные данные | не рассматриваются | основание обработки, политика, отдельное согласие там, где оно требуется |
| Журнал | error_log без текста заявки |
то же, плюс срок хранения и доступ только у ответственных |
| HTTPS | не нужен для локальной проверки | на странице формы и на обработчике |
Персональные данные — отдельный вопрос, а не шаблонная галочка. Имя и email в заявке — это персональные данные, поэтому сначала определите цель (ответить на обращение) и основание обработки: согласие — одно из оснований в ст. 6 152-ФЗ, но не единственное. Цель, состав данных, сроки и порядок обработки опишите в политике, ссылку на нее поставьте рядом с формой. Отдельное согласие добавьте там, где оно требуется для выбранной цели, например если заявку хотят использовать для рассылок. Конкретную схему для своего сайта стоит согласовать с юристом.
Вложения файлов — отдельная задача: проверка типа и размера, хранение вне корня сайта.
Если не получилось
| Симптом | Вероятная причина | Что сделать |
|---|---|---|
Вместо отправки браузер скачивает или показывает код send.php |
PHP не подключен к веб-серверу | Проверить обработчик PHP на хостинге, открыть страницу по http, а не как файл |
| 500 «Не удалось отправить» | mail() вернул false: нет sendmail или он запрещен |
Посмотреть журнал ошибок PHP, перейти на SMTP |
| Редирект на «спасибо» есть, письма нет | Письмо в спаме или отклонено | Проверить папку «Спам», SPF/DKIM/DMARC, From на своем домене |
| Тема письма нечитаемая | Тема не закодирована по MIME | mb_encode_mimeheader или PHPMailer с CharSet = 'UTF-8' |
| Любая отправка дает 429 | Все посетители идут с IP прокси | Брать реальный IP из заголовка доверенного прокси или ставить лимит на веб-сервере |
Выводы
- HTML-форма только отправляет данные; письмо формирует серверный обработчик, а доставляет sendmail или SMTP.
- From — адрес на домене сайта, email посетителя — в Reply-To; иначе письма уходят в спам.
- На сервере проверяются тип, длина и формат каждого поля: атрибуты
requiredиmaxlengthбота не останавливают. - Заголовки письма передаются массивом, перевод строки в полях запрещен — это закрывает инъекцию заголовков.
- Без своего сервера подойдут ссылка
mailto:или конструктор форм; для надежной доставки со своего сайта — SMTP через PHPMailer.
Где применяется / связь с практикой
Освойте тему на практике
Форма обратной связи — одна из первых серверных задач веб-разработчика: в ней сразу встречаются обработка HTTP-запроса, валидация, безопасность и работа с внешним сервисом. Дальше те же навыки нужны для регистрации, оплаты и API. Если хотите системно разобраться в серверной разработке на PHP, посмотрите курс «PHP-разработчик. Продвинутый уровень». Бесплатно попробовать формат можно на открытых уроках.
FAQ
Можно ли отправить форму на почту без PHP и без сервера?
Своими силами — только ссылкой mailto:, которая открывает почтовую программу посетителя. Атрибут action="mailto:..." у формы работает ненадежно и зависит от браузера. Остальные варианты — конструкторы форм и сервисы-обработчики, которые принимают POST за вас.
Как отправить форму без перезагрузки страницы?
Перехватить событие submit в JavaScript и отправить new FormData(form) через fetch. Обработчик на сервере остается тем же, но ответ удобнее отдавать в JSON, а все проверки на сервере сохраняются.
Можно ли отправлять письмо с адреса посетителя, чтобы ответить прямо ему?
Нет, адрес посетителя ставят в Reply-To: кнопка «Ответить» в почте все равно уйдет ему. Письмо с чужим доменом в From не пройдет SPF/DMARC.



