Форма обратной связи на HTML с отправкой на почту: пример на PHP

Форма обратной связи на HTML с отправкой на почту: пример на PHP Полезное

Форма обратной связи с отправкой на почту — это HTML-форма, которая передает введенные данные на сервер, и серверный скрипт, который проверяет их и отправляет письмо на ваш адрес. Сам HTML письма не отправляет: тег <form> только собирает поля и делает POST-запрос по адресу из action. Письмо формирует обработчик (ниже — на PHP), а доставляет почтовый транспорт: локальный sendmail или SMTP-сервер.

Ниже — полный рабочий пример «форма + обработчик», проверка без настоящей почты, защита от спама и от подмены заголовков письма, вариант через 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.

OTUS Журнал
Скидка 5% 12-19 октября на курсы (popup)
Бесплатные открытые уроки (поп-ап)