SSD Nodes Learn Hosting plans →
Руководства Matt ConnorАвтор: Matt Connor

Как поднять временный почтовый сервер на VPS с Docker

Разверните Mailpit на своем VPS для перехвата тестовых писем. Инструкция поможет настроить SMTP-сервер в Docker Compose, чтобы исключить отправку сообщений реальным клиентам.

Что такое временный почтовый ящик

Временный почтовый ящик — это небольшой SMTP-сервер (simple mail transfer protocol), который принимает почту для любого адреса, но не доставляет её получателям. Ваше приложение на этапе тестирования отправляет письма на этот сервер вместо реального почтового провайдера, и все сообщения остаются внутри. Вы просматриваете входящие через веб-интерфейс, поэтому ошибка в списке получателей или некорректный шаблон не имеют последствий: почта не покидает пределы ящика.

В этом руководстве мы развернём такой сервер на одном VPS с помощью Docker Compose. В качестве «чёрной дыры» для всех писем используется Mailpit. Его SMTP-слушатель привязан к адресу, доступному только вашему приложению, веб-интерфейс защищён nginx с использованием TLS и пароля, а ограничение на хранение писем предотвращает переполнение диска. Если вы не знакомы с Compose, в основах Compose для VPS описана структура файлов, которую предполагает данное руководство.

Результатом станет инструмент для тестирования, а не почтовый сервер. В нём нет учётных записей, доставки почты и фильтрации спама. Для создания реальных почтовых ящиков для пользователей лучше использовать полноценный почтовый сервер, такой как Mailcow, что является гораздо более сложной задачей.

Mailpit, Inbucket или MailHog: какой почтовый sink выбрать

Эту задачу решают три инструмента. Различия между ними заключаются в статусе поддержки, используемых портах и возможностях обработки сообщений после их получения. Указанные ниже версии актуальны на октябрь 2026 года.

MailHog (mailhog/mailhog) использует порт 1025 для SMTP и 8025 для веб-интерфейса. Он всё ещё работает. В основной ветке не было коммитов с августа 2022 года, а в трекере открыто более 200 задач, поэтому вы будете использовать зависимости без обновлений безопасности в своей тестовой среде. Не начинайте новые проекты с его использованием.

Inbucket (inbucket/inbucket) использует порт 2500 для SMTP, 9000 для веб-интерфейса и 1100 для POP3 (post office protocol version 3). Версия 3.2.0 вышла 10 октября 2026 года. Он сохраняет сообщения в виде файлов в /storage и самостоятельно удаляет их: образ использует настройки INBUCKET_STORAGE_RETENTIONPERIOD=72h и INBUCKET_STORAGE_MAILBOXMSGCAP=300. Выбирайте его, если для теста необходимо собирать почту через POP3-клиент, а не через HTTP-запросы.

Mailpit (axllent/mailpit) использует те же порты, что и MailHog — 1025 и 8025, поэтому он заменяет MailHog без изменения конфигурации приложений. Версия 1.31.5 была выпущена 11 октября 2026 года; ветка 1.30 завершилась на версии 1.30.7 в августе. Внутри бинарного файла содержатся все необходимые для этого руководства функции: файл паролей для веб-интерфейса и API (application programming interface), ограничение количества сообщений, ограничение по времени хранения и фильтр получателей. В оставшейся части руководства используется Mailpit.

Принцип работы catch-all и отсутствие участия DNS

Ваше приложение не выполняет поиск адреса для доставки. Вы передаете ему хост и порт, оно открывает TCP-соединение и объявляет RCPT TO:<anyone@example.test>. Mailpit принимает этого получателя независимо от того, что указано, сохраняет сообщение и ничего не пересылает. Домен не разрешается, поэтому example.test работает, даже если .test — это зарезервированное имя, которого не существует в системе доменных имен (DNS).

Это весь механизм, и именно поэтому почтовый ящик по умолчанию безопасен. Записи MX (mail exchanger) не используются, попытки доставки не предпринимаются, и ни одно сообщение не может попасть к реальному человеку.

Настройка приложения на использование приемника

Укажите в качестве SMTP-хоста приложения mailpit, если приложение работает как контейнер в том же проекте Compose, или 127.0.0.1, если оно запущено на хосте. Установите порт 1025, отключите TLS и оставьте поля имени пользователя и пароля пустыми. Mailpit принимает почту без аутентификации.

Некоторые фреймворки отказываются отправлять письма без учетных данных. Параметр MP_SMTP_AUTH_ACCEPT_ANY=1 заставляет Mailpit принимать любые имя пользователя и пароль, а MP_SMTP_AUTH_ALLOW_INSECURE=1 разрешает использование механизмов PLAIN и LOGIN при незашифрованном соединении. Эти две настройки безопасны в данном случае только потому, что прослушивающий порт недоступен из интернета, что обеспечивается приведенной ниже конфигурацией развертывания.

Параметр MP_SMTP_ALLOWED_RECIPIENTS стоит настроить с самого начала. Он принимает регулярное выражение и отклоняет всех получателей, которые ему не соответствуют. Укажите в нем свой тестовый домен, и тогда, если в базе данных для тестирования остался реальный адрес клиента, приложение выдаст явную ошибку в логах вместо того, чтобы тихо отправить сообщение в приемник.

Файл Docker Compose

Сначала создайте каталог и файл паролей для веб-интерфейса. htpasswd -B записывает хеш bcrypt, а Mailpit поддерживает как bcrypt, так и открытый текст.

mkdir -p ~/mailpit/data
cd ~/mailpit
sudo apt update && sudo apt install -y apache2-utils
htpasswd -B -c data/ui-auth qa

Создайте compose.yaml:

services:
  mailpit:
    image: axllent/mailpit:v1.31
    container_name: mailpit
    restart: unless-stopped
    ports:
      - "127.0.0.1:8025:8025"
      - "127.0.0.1:1025:1025"
    volumes:
      - ./data:/data
    environment:
      MP_DATABASE: /data/mailpit.db
      MP_MAX_MESSAGES: 2000
      MP_MAX_AGE: 14d
      MP_UI_AUTH_FILE: /data/ui-auth
      MP_SMTP_AUTH_ACCEPT_ANY: 1
      MP_SMTP_AUTH_ALLOW_INSECURE: 1
      MP_SMTP_ALLOWED_RECIPIENTS: '@example\.test$$'

Удвоенный знак доллара — это не опечатка. Compose воспринимает одиночный $ как начало переменной для подстановки, поэтому $$ используется для передачи одного литерального знака доллара в контейнер. Регулярное выражение доходит до Mailpit как @example\.test$.

Запустите сервис и проверьте его состояние:

docker compose up -d
docker compose ps

В столбце STATUS должно быть значение Up ... (healthy). Образ содержит встроенную проверку работоспособности (healthcheck), которая выполняет /mailpit readyz каждые 15 секунд. Если контейнер остается в состоянии starting или переходит в unhealthy, это обычно означает, что сервис не отвечает на порту 8025 внутри контейнера. Проверка считывает настройки из переменных окружения MP_, но не из флагов командной строки; именно поэтому в данном руководстве все параметры задаются через environment:. Если передать параметры через флаги, проверка может сообщать о unhealthy, хотя Mailpit будет работать нормально. Ознакомьтесь с docker compose logs mailpit перед внесением любых изменений.

Оба опубликованных порта привязаны к адресу, и этот адрес является элементом контроля безопасности. Внутри контейнера Mailpit слушает 0.0.0.0, что допустимо, так как у контейнера собственное сетевое пространство имен. Левая часть маппинга определяет, кто получит доступ снаружи. Если указать 8025:8025, Docker привяжет сервис ко всем адресам хоста, включая публичный.

Если ваше приложение для тестирования (staging) находится в этом же файле, удалите маппинг 1025 и укажите приложению хост mailpit на порту 1025. Контейнеры в общей сети Compose взаимодействуют напрямую, поэтому SMTP-порт вообще не будет доступен на хосте. В разделе Как сети Compose разрешают имена сервисов описан этот механизм поиска.

Отправка сообщения и проверка его доставки

python3 - <<'EOF'
import smtplib
from email.message import EmailMessage

m = EmailMessage()
m["From"] = "staging@example.test"
m["To"] = "anyone@example.test"
m["Subject"] = "Mailpit smoke test"
m.set_content("If this appears in the web interface, the sink works.")
with smtplib.SMTP("127.0.0.1", 1025) as s:
    s.send_message(m)
EOF

При успешном выполнении скрипт не выводит сообщений. Подтвердите, что сообщение сохранено, через API:

curl -s -u qa:yourpassword http://127.0.0.1:8025/api/v1/messages

Этот запрос возвращает JSON со списком сохранённых сообщений. Если убрать флаг -u, запрос будет отклонён, так как MP_UI_AUTH_FILE защищает API и веб-интерфейс одновременно. Любой тест, считывающий содержимое входящих сообщений, должен передавать эти учётные данные.

Ошибка ConnectionRefusedError в Python-скрипте означает, что на порту 127.0.0.1:1025 нет активных слушающих процессов. Это ожидаемый результат, если вы удалили SMTP-маппинг; в таком случае проверку необходимо запускать из контейнера, находящегося в той же сети Docker Compose.

Публикация веб-интерфейса через nginx с защитой паролем

В данный момент интерфейс отвечает только на адресе loopback. nginx выполняет TLS termination и запрашивает пароль до того, как запрос попадет в приложение.

sudo htpasswd -B -c /etc/nginx/mailpit.htpasswd qa
server {
    listen 443 ssl;
    server_name mail-test.example.com;

    ssl_certificate     /etc/letsencrypt/live/mail-test.example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/mail-test.example.com/privkey.pem;

    auth_basic           "mailpit";
    auth_basic_user_file /etc/nginx/mailpit.htpasswd;

    location / {
        proxy_pass http://127.0.0.1:8025;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}

Перезагрузите конфигурацию после проверки синтаксиса командой sudo nginx -t && sudo systemctl reload nginx. Если вы настраиваете прокси впервые, стоит прочитать что делает каждая директива в блоке reverse proxy.

Используйте одни и те же имя пользователя и пароль в файле nginx и в data/ui-auth. nginx передает заголовок Authorization браузера вышестоящему приложению, поэтому совпадающие учетные данные позволяют пройти обе проверки после одного запроса. Если учетные данные различаются, браузер будет хранить один набор, который вторая проверка отклонит.

Заголовки Upgrade и Connection — это не просто украшение. Mailpit отправляет новые письма на открытую страницу через WebSocket, и прокси, работающий по протоколу HTTP/1.1 без этих заголовков, не сможет выполнить обновление соединения (upgrade). В этом случае страница загрузится корректно, но не будет обновляться: письма приходят, API их видит, но список остается статичным до принудительной перезагрузки страницы.

Оставьте оба уровня защиты. Пароль nginx защищает публичный адрес, а MP_UI_AUTH_FILE защищает сам порт 8025. Это важно, так как любая ссылка на сброс пароля, сгенерированная вашим тестовым приложением, доступна в этом интерфейсе до тех пор, пока она хранится в системе.

Не допускайте превращения sink в открытый релей

Открытый релей — это SMTP-сервер, который принимает сообщения от любого отправителя и пересылает их по любому адресу. Спамеры постоянно сканируют сеть в их поиске, и обнаружение такого сервера на вашем IP-адресе приведет к жалобам на злоупотребления и блокировке учетной записи.

Mailpit не является открытым релеем по умолчанию, так как он не пересылает сообщения без настройки. У него есть две функции исходящей почты, и обе отключены, пока вы их не настроите. MP_SMTP_RELAY_CONFIG указывает на файл конфигурации релея и активирует действие отправки в интерфейсе, которое пересылает выбранное вами сообщение через указанный сервер; для автоматической пересылки требуется дополнительный переключатель, MP_SMTP_RELAY_ALL (по умолчанию false) или MP_SMTP_RELAY_MATCHING. Пересылка (forwarding), настраиваемая через MP_SMTP_FORWARD_CONFIG или переменные MP_SMTP_FORWARD_*, копирует каждое входящее сообщение на выбранные вами фиксированные адреса. Если оставить все эти параметры не заданными, это будет осознанным решением.

Существует два способа утратить это свойство. Включите MP_SMTP_RELAY_ALL, который отправляет каждое входящее сообщение исходным получателям и игнорирует MP_SMTP_ALLOWED_RECIPIENTS, затем откройте SMTP-порт в Интернет — и вы создали работающий открытый релей. MP_SMTP_RELAY_MATCHING делает то же самое для любого получателя, соответствующего шаблону. Если открыть порт без настройки релея, посторонние не смогут отправлять почту через вас, но они смогут заполнить ваше хранилище и внедрить контент в интерфейс, которому доверяет ваша команда. При включенной пересылке их сообщения также попадут во все почтовые ящики, получающие копии.

Ловушка при работе на Docker-хосте заключается в брандмауэре. Публикация порта заставляет Docker записывать собственные правила в таблицу nat, и трафик, предназначенный для контейнера, обрабатывается там до того, как правила ufw (uncomplicated firewall) вступят в силу. sudo ufw deny 1025/tcp сообщает об успехе, но ничего не меняет. Почему Docker публикует порты в обход ufw подробно описывает порядок цепочек.

Решение заключается в указании адреса в привязке (mapping), а не в правиле брандмауэра. Проверьте, к какому адресу привязан сервис:

sudo ss -ltnp | grep -E ':(1025|8025)'

Корректный вывод показывает 127.0.0.1:1025 и 127.0.0.1:8025. Строка с 0.0.0.0:1025 означает, что привязка потеряла адрес и sink слушает весь Интернет. При попытке подключения с другого компьютера nc -vz mail-test.example.com 1025 должно завершиться по таймауту или отказом в соединении.

Если приложение находится на другом сервере, не открывайте порт 1025 для связи между ними. Разместите обе машины в частной сети или VPN-туннеле и привяжите порт к адресу этого сетевого интерфейса.

Публикуйте MX-записи только в том случае, если вам нужна реальная входящая почта

MX-запись (mail exchanger) указывает другим почтовым серверам, какой хост принимает почту для домена. Её отсутствие само по себе не закрывает доступ: если у домена нет MX-записи, отправляющие серверы переходят к его адресной записи (A или AAAA), как это определено в RFC 5321, раздел 5.1, а у хостнейма, который вы используете для веб-интерфейса, такая запись есть. Почта из интернета не попадает на сервер только потому, что ни один процесс на нём не слушает порт 25. В почтовом ящике находится только то, что отправили ваши собственные приложения, для чего и предназначен тестовый ящик.

Для получения реальной почты необходима DNS-запись, направляющая отправляющие серверы на ваш хост (MX-запись или адресная запись, используемая по умолчанию), Mailpit, прослушивающий порт 25 (MP_SMTP_BIND_ADDR=0.0.0.0:25), и открытый порт. С этого момента вы запускаете публичный «catch-all» для всех адресов в домене. Чётко осознавайте последствия:

  • Спам начинает поступать через несколько дней после появления записи, так как сборщики адресов сканируют DNS. Словарные атаки перебирают распространённые имена и сохраняют сообщение для каждой попытки.
  • Вложения от незнакомцев попадают на ваш диск и остаются там. Ничто их не фильтрует, поэтому архив от неизвестного отправителя будет лежать рядом с вашей тестовой почтой.
  • Любой, кто узнает домен, может зарегистрироваться на сторонних сервисах, используя адрес в этом домене, и письмо с подтверждением будет доставлено на ваш сервер. Если защита паролем когда-либо даст сбой, эти учётные записи будут принадлежать тому, кто читает входящую почту.
  • Лимиты на хранение перестают быть просто обслуживанием и становятся критически важными, так как объём почты больше не находится под вашим контролем.

Если вам нужна реальная входящая почта для проверки доставляемости, выделите под неё отдельный поддомен, ограничьте MP_MAX_AGE и относитесь ко всему содержимому как к публичному. Если вам нужны почтовые ящики, на которые можно положиться, используйте полноценный почтовый сервер с фильтрацией и резервным копированием.

Хранение: как неограниченный catch-all заполняет диск

Mailpit по умолчанию хранит 500 сообщений и периодически удаляет самые старые из них. Флаг MP_MAX_MESSAGES: 0 отключает это ограничение, и если не задан MP_MAX_AGE, сообщения никогда не удаляются автоматически. Именно так catch-all заполняет диск, пока этого никто не замечает. Параметр MP_MAX_AGE добавляет ограничение по времени; он работает совместно с лимитом количества сообщений или вместо него и принимает значения в часах или днях, записанные как 36h или 14d.

Флаг MP_DATABASE определяет, сохраняются ли данные после перезапуска. Без него Mailpit записывает данные во временный файл, который удаляется при завершении процесса, поэтому каждый перезапуск очищает папку «Входящие». С этим флагом почта сохраняется после перезагрузок, и файл продолжает расти.

Место занимают вложения. Ежедневное задание, которое отправляет PDF-отчет размером 2 MB на 300 тестовых адресов, создает нагрузку в 600 MB за ночь, и лимит по количеству сообщений не успеет сработать вовремя. Рассчитывайте этот рост с учетом других данных на разделе, так как «тяжелые» мультимедийные приложения, такие как PhotoPrism или Immich, скорее всего, уже заняли большую часть диска на небольшом VPS.

du -h ~/mailpit/data/mailpit.db
df -h /

Очищайте хранилище между запусками CI, вместо того чтобы ждать срабатывания лимита:

curl -s -u qa:yourpassword -X DELETE http://127.0.0.1:8025/api/v1/messages

Inbucket решает ту же проблему с помощью INBUCKET_STORAGE_RETENTIONPERIOD (72h в образе) и INBUCKET_STORAGE_MAILBOXMSGCAP (300). Что бы вы ни использовали, установите лимиты до того, как начнете направлять туда трафик из первого набора тестов.

Чтение входящих сообщений из тестового набора

GET /api/v1/messages выводит список сохраненных сообщений, GET /api/v1/message/{ID} возвращает одно сообщение с его частями и заголовками, GET /api/v1/search выполняет фильтрацию, а DELETE /api/v1/messages очищает хранилище. Интерактивная документация для используемой вами версии доступна по адресу http://127.0.0.1:8025/api/v1/.

Полезный тест отправляет сообщение, опрашивает хранилище до его появления, проверяет тему и ссылку внутри, а затем удаляет все данные. Используйте короткий цикл повторных попыток вместо одного запроса, так как приложение, ставящее почту в очередь фонового обработчика, завершает вызов отправки до того, как сообщение попадет в Mailpit. Тот же шаблон встречается в инструментах для тестирования и имитации API при самостоятельном хостинге, которые обычно составляют вторую половину среды тестирования, изолированной от production.

FAQ

Является ли self-hosted ящик для временной почты открытым релеем?

Нет, если релей выключен. По умолчанию Mailpit ничего не пересылает: MP_SMTP_RELAY_CONFIG позволяет только вручную отправлять выбранные сообщения, а для автоматического релея требуются MP_SMTP_RELAY_ALL или MP_SMTP_RELAY_MATCHING. Если оставить их не заданными, посторонний человек, подключившийся к порту 1025, не сможет отправить почту через ваш сервер. Он всё ещё может заполнить хранилище, поэтому привязывайте SMTP-порт только к адресу, доступному вашему приложению. Публикация порта через 1025:1025 в Compose привязывает его ко всем адресам хоста, а sudo ufw deny 1025/tcp не закроет его, так как правила NAT в Docker имеют приоритет.

Нужна ли MX-запись для тестового домена?

Только если вы хотите получать почту из Интернета. Отсутствие записи само по себе не является защитой. Без MX-записи серверы-отправители обращаются к A или AAAA-записи домена, поэтому от почты из Интернета защищает только отсутствие слушающего процесса на порту 25. В этом случае в ящике будут только письма, отправленные вашими приложениями по SMTP. Если вы направите DNS на сервер и откроете порт 25, вы получите публичный catch-all ящик: через несколько дней там будет спам, словарные атаки, сохраняющие по сообщению на каждую попытку, и вложения от посторонних на вашем диске без какой-либо фильтрации.

Почему список сообщений обновляется только при перезагрузке страницы?

Mailpit отправляет новую почту на открытую страницу через WebSocket. Блок location в nginx без proxy_http_version 1.1 и заголовков Upgrade и Connection не может выполнить upgrade соединения, поэтому страница загружается обычным образом и «замирает». Почта при этом продолжает приходить, а API её возвращает, поэтому ящик выглядит не сломанным, а просто неактуальным. Добавьте эти строки, перезагрузите nginx и обновите страницу.

Как предотвратить заполнение диска почтовым ящиком?

Оставьте MP_MAX_MESSAGES равным реальному числу и добавьте MP_MAX_AGE. Лимит по умолчанию составляет 500 сообщений, а установка значения 0 отключает удаление по количеству. Если не задан MP_MAX_AGE, ничего не будет удаляться, из-за чего catch-all ящик с вложениями будет незаметно расти. MP_MAX_AGE принимает значения в часах или днях, например 36h или 14d. Очищайте хранилище при завершении CI с помощью curl -u qa:yourpassword -X DELETE http://127.0.0.1:8025/api/v1/messages, так как API требует те же учетные данные, что и веб-интерфейс. Inbucket выполняет ту же задачу с помощью INBUCKET_STORAGE_RETENTIONPERIOD (72ч) и INBUCKET_STORAGE_MAILBOXMSGCAP (300).

Что выбрать: Mailpit, Inbucket или MailHog?

Для новых проектов по состоянию на октябрь 2026 года — Mailpit. MailHog всё ещё работает, но в его основной ветке не было коммитов с августа 2022 года, поэтому он поставляется с уязвимыми зависимостями. Inbucket активно поддерживается (версия 3.2.0, октябрь 2026) и является лучшим выбором, если для тестов нужен POP3, так как POP3-сервер Mailpit запускается только после предоставления учетных данных в файле паролей или в переменной MP_POP3_AUTH. Mailpit использует те же порты, что и MailHog (1025 и 8025), поэтому замена MailHog требует лишь изменения имени образа в файле Compose.