Браузер видит новый сертификат mailcow, а Outlook по IMAP — просроченный: куда он на самом деле передаётся
Если HTTPS у mailcow зелёный, а настольные почтовые клиенты по IMAP и SMTP ругаются на просроченный сертификат — прокси и почтовые службы получили сертификат из разных мест. Веб-интерфейс отдаёт то, что видит обратный прокси, а Postfix и Dovecot читают файлы напрямую из data/assets/ssl/. Разбираю, где физически лежит сертификат для каждой службы, что нужно сделать при внешнем ACME-клиенте и как проверить каждый протокол отдельно, не полагаясь на браузер.
Кейс: HTTPS работает, а сотрудники не могут получить почту с телефона
«НьюБизнес Лаб» — инкубатор стартапов, 26 рабочих мест, резиденты меняются раз в несколько месяцев, и у каждой команды свой почтовый домен на общем mailcow. Веб-почту используют не все — часть резидентов настраивает корпоративную почту на телефонах и в Thunderbird как обычно, через IMAP и SMTP. Сертификат Let's Encrypt у них выпускал не встроенный ACME-клиент mailcow, а внешний реверс-прокси на входе в инфраструктуру — так исторически сложилось, потому что через тот же прокси проходит ещё несколько внутренних сервисов инкубатора.
Прокси продлил сертификат по плану, за месяц до истечения, и все сервисы за ним обновились сами. Веб-интерфейс SOGo открывался нормально, замочек в браузере зелёный, срок действия свежий. А ровно через месяц, в понедельник утром, в поддержку посыпались жалобы: у половины резидентов Thunderbird и мобильная почта на iPhone стали ругаться на просроченный сертификат при подключении, хотя в пятницу всё работало. Администратор первым делом проверил сертификат в браузере ещё раз — убедился, что дата свежая, — и завис в недоумении: сертификат ведь обновили, а клиенты видят старый.
Дело в том, что HTTPS для веб-интерфейса и шифрование для IMAP/SMTP в mailcow — это не один и тот же путь до сертификата, хотя выглядит как единая система. Обратный прокси перед mailcow обслуживает только HTTP/HTTPS-трафик и ничего не знает про порты 993 (IMAPS) и 465/587 (SMTPS/STARTTLS) — на них напрямую отвечают контейнеры dovecot-mailcow и postfix-mailcow, у которых свой собственный, отдельно читаемый набор файлов сертификата.
- сертификат продлевали через внешний ACME на реверс-прокси, а не встроенным клиентом mailcow
- HTTPS в браузере — свежий сертификат, IMAP/SMTP в почтовых клиентах — старый и просроченный
- HTTPS обслуживает прокси/nginx-mailcow, а IMAPS/SMTPS напрямую dovecot-mailcow и postfix-mailcow
- это два разных потребителя одного логического сертификата, с разными точками, куда его нужно доставить
Куда mailcow на самом деле складывает сертификат для почтовых служб
По умолчанию mailcow сам получает и обновляет сертификаты Let's Encrypt через собственный ACME-клиент, и тогда вся эта механика прозрачна — контейнер acme-mailcow кладёт свежий сертификат в единое место и перезапускает нужные службы сам. Проблема начинается ровно тогда, когда встроенный ACME-клиент отключают, потому что сертификатами занимается что-то снаружи — внешний certbot, Traefik, corporate PKI или, как в случае с инкубатором, общий реверс-прокси инфраструктуры. Для этого в mailcow.conf есть переменная SKIP_LETS_ENCRYPT=y, которая останавливает собственную попытку mailcow получить сертификат; чтобы она применилась, документация требует пересоздать контейнер acme-mailcow (docker compose up -d).
После этого ответственность за доставку сертификата в нужное место полностью переходит на администратора. Документация прямо указывает единственный официальный путь: полную цепочку сертификата (сам сертификат плюс промежуточный CA, если он есть) нужно сохранить в data/assets/ssl/cert.pem, а соответствующий приватный ключ — в data/assets/ssl/key.pem. И отдельным пунктом с пометкой IMPORTANT: файлы нужно именно копировать, символические ссылки на файлы, лежащие где-то ещё (например, в каталоге Let's Encrypt другого сервиса), использовать нельзя.
Именно эти два файла и читают напрямую postfix-mailcow для SMTPS/STARTTLS и dovecot-mailcow для IMAPS/POP3S — без какого-либо посредничества реверс-прокси. Прокси в этой схеме обслуживает только порты 80/443 для веб-интерфейса и, возможно, свои собственные сервисы, а про то, что происходит на 993 и 465/587 портах, вообще ничего не знает и знать не должен. Поэтому обновление сертификата на прокси в принципе не может само по себе долететь до Dovecot и Postfix — это два независимых потребителя одного и того же логического сертификата, и синхронизировать их должен именно администратор, руками или автоматическим хуком.
- SKIP_LETS_ENCRYPT=y в mailcow.conf + пересоздание acme-mailcow — отключает встроенный ACME-клиент
- полная цепочка → data/assets/ssl/cert.pem, приватный ключ → data/assets/ssl/key.pem
- документация прямо запрещает символические ссылки — только копирование файлов
- postfix-mailcow и dovecot-mailcow читают эти файлы напрямую, реверс-прокси в этом не участвует
Post-hook для внешнего ACME-клиента: автоматизируем разовую боль
Чтобы не повторять ручное копирование при каждом продлении — а сертификат Let's Encrypt по умолчанию живёт 90 дней, и certbot продлевает его примерно за 30 дней до истечения — документация mailcow описывает вариант с post-hook скриптом, который запускается внешним ACME-клиентом сразу после успешного получения нового сертификата. Идея простая: скрипт копирует свежие fullchain.pem и privkey.pem из каталога, где их хранит внешний клиент (например certbot кладёт их в /etc/letsencrypt/live/домен/), в data/assets/ssl/cert.pem и data/assets/ssl/key.pem внутри каталога mailcow, а затем перезапускает три контейнера, которые реально используют TLS: postfix-mailcow, dovecot-mailcow и nginx-mailcow.
#!/usr/bin/env bash
# post-hook certbot: /etc/letsencrypt/renewal-hooks/deploy/mailcow-deploy.sh
MAILCOW_DIR=/opt/mailcow-dockerized
DOMAIN=mail.example.com
cp /etc/letsencrypt/live/$DOMAIN/fullchain.pem $MAILCOW_DIR/data/assets/ssl/cert.pem
cp /etc/letsencrypt/live/$DOMAIN/privkey.pem $MAILCOW_DIR/data/assets/ssl/key.pem
# хук вызывается после продления любого домена — реагируем только на свой
[ "$RENEWED_LINEAGE" = "/etc/letsencrypt/live/$DOMAIN" ] || exit 0
docker restart $(docker ps -qaf name=postfix-mailcow) $(docker ps -qaf name=dovecot-mailcow) $(docker ps -qaf name=nginx-mailcow)Обратите внимание на команду перезапуска — в документации она дана именно как docker restart $(docker ps -qaf name=...) (в примере post-hook — одной командой для трёх контейнеров), а не как docker compose restart служба. Разница практическая: docker compose restart требует, чтобы скрипт запускался из каталога с docker-compose.yml и знал точное имя сервиса из docker-compose.yml, а docker ps -qaf name=... находит контейнер по маске имени независимо от текущей директории — это удобнее для post-hook скрипта, который запускается внешним ACME-клиентом со своим рабочим каталогом, а не из-под mailcow.
Certbot поддерживает такие хуки нативно через каталог /etc/letsencrypt/renewal-hooks/deploy/ — любой исполняемый скрипт там запускается автоматически после успешного продления любого домена, а путь к продлённому сертификату certbot передаёт в переменной RENEWED_LINEAGE — поэтому в примере выше стоит проверка, чтобы продление соседнего домена не перезапускало почту. Если у вас Traefik или другой ACME-клиент — принцип тот же, но механизм подключения хука будет другим, нужно смотреть в его собственной документации, как выполнить произвольную команду после выпуска сертификата.
Проверять такой хук на боевом сертификате рискованно: реальное продление случится только через месяцы, а до тех пор ошибка в скрипте останется незамеченной. Certbot закрывает эту проблему флагом --dry-run — он проходит весь процесс продления против тестового окружения Let's Encrypt, не трогая боевой сертификат, а на выходе всё равно выполняет deploy-hook, если добавить к вызову --deploy-hook /etc/letsencrypt/renewal-hooks/deploy/mailcow-deploy.sh вручную. Права на сам скрипт должны быть chmod +x, иначе certbot его молча пропустит, а в логе /var/log/letsencrypt/letsencrypt.log останется только смутное упоминание, что deploy-hook не запустился. Я в первый прогон всегда добавляю в скрипт строку logger -t mailcow-cert-hook "renewed and restarted", чтобы потом одной командой journalctl -t mailcow-cert-hook убедиться, что хук действительно сработал в прошлый раз, а не просто лежит в каталоге как красивое намерение.
- post-hook копирует fullchain.pem/privkey.pem в data/assets/ssl/cert.pem и key.pem
- перезапуск нужен для трёх контейнеров: postfix-mailcow, dovecot-mailcow, nginx-mailcow
- официальная команда — docker restart $(docker ps -qaf name=...), не завязана на рабочий каталог
- certbot: каталог /etc/letsencrypt/renewal-hooks/deploy/ подхватывает скрипт автоматически после продления
Как проверить каждый протокол отдельно, не веря браузеру на слово
Самая частая ошибка при диагностике таких инцидентов — судить обо всём сертификате по одному-единственному протоколу, обычно HTTPS в браузере, потому что это самое заметное и быстро проверяемое место. Правильная диагностика — прогнать каждый протокол, который использует TLS, отдельной командой и сравнить срок действия сертификата на каждом порту. Документация mailcow (Advanced SSL) для этого приводит openssl s_client для портов 587, 465, 143, 993 и 443 — с ключом -starttls для протоколов со STARTTLS.
# IMAPS, порт 993 — TLS с самого начала соединения
openssl s_client -connect mail.example.com:993 -servername mail.example.com </dev/null 2>/dev/null | openssl x509 -noout -dates
# SMTP с STARTTLS, порт 587
openssl s_client -starttls smtp -connect mail.example.com:587 -servername mail.example.com </dev/null 2>/dev/null | openssl x509 -noout -dates
# SMTPS, порт 465 — implicit TLS
openssl s_client -connect mail.example.com:465 -servername mail.example.com </dev/null 2>/dev/null | openssl x509 -noout -dates
# HTTPS для сравнения
openssl s_client -connect mail.example.com:443 -servername mail.example.com </dev/null 2>/dev/null | openssl x509 -noout -datesФлаг -servername важен даже для однодоменной установки — без него при наличии на сервере нескольких сертификатов за одним IP (SNI) можно случайно получить не тот сертификат и запутать диагностику ещё больше. Три команды дают три независимых ответа notBefore/notAfter, и если по HTTPS дата свежая, а по 993 и 587 — старая, это прямое подтверждение, что прокси и почтовые контейнеры используют разные файлы сертификата, ровно как было в инкубаторе.
Отдельно стоит проверить, что сами файлы в data/assets/ssl/ — не символические ссылки, если в прошлом кто-то пытался сделать «универсальное» решение и слинковать их с каталогом Let's Encrypt: ls -la data/assets/ssl/cert.pem data/assets/ssl/key.pem. Символическая ссылка может смотреть на путь, недоступный изнутри контейнера (например, на хостовый путь, не примонтированный в dovecot-mailcow), — внутри контейнера такая ссылка битая: пока служба не перезапущена, она держит в памяти старый сертификат, а после перезапуска не сможет прочитать файл вовсе.
В инкубаторе причина оказалась именно в отсутствии post-hook: администратор продлевал сертификат на реверс-прокси и вручную копировал его в mailcow «когда вспоминал», а в этот раз забыл: прокси продлил сертификат за 30 дней до истечения, копия в data/assets/ssl/ осталась от прошлого цикла и через месяц честно истекла. После настройки автоматического post-hook в certbot и разового ручного копирования актуального сертификата проблема ушла полностью, и с тех пор уже два цикла продления прошли без единой жалобы.
- проверять IMAPS (993), SMTP STARTTLS (587) и HTTPS (443) отдельными командами openssl s_client, не полагаясь на браузер
- -servername обязателен при нескольких сертификатах на одном IP (SNI)
- ls -la на cert.pem/key.pem — убедиться, что это реальные файлы, а не битые символические ссылки
- пропущенный вручную цикл копирования — самая частая причина, автоматизация post-hook закрывает её раз и навсегда
Что делать, если вы ещё не решили, кто выпускает сертификаты
Если инфраструктура ещё небольшая и mailcow — единственный сервис за прокси или вообще стоит напрямую, я в большинстве случаев советую не изобретать схему с внешним ACME-клиентом, а оставить встроенный ACME mailcow — он сам получает сертификат, сам кладёт его в нужное место и сам перезапускает нужные контейнеры, без единого дополнительного скрипта, который может однажды перестать срабатывать. Внешний ACME-клиент оправдан, когда сертификатами централизованно управляет что-то ещё — например, единый Traefik или corporate PKI перед десятком сервисов, и заводить для mailcow отдельный процесс получения было бы избыточно. Именно такой случай был у инкубатора — там прокси обслуживает не только почту.
Если у вас порт 80 закрыт периметровым фаерволом и встроенный HTTP-01 ACME mailcow физически не может отработать, это не повод сразу переходить на внешний клиент — у mailcow есть встроенная поддержка DNS-01 challenge (ACME_DNS_CHALLENGE=y и ACME_DNS_PROVIDER в mailcow.conf, внутри работает acme.sh), которая не требует открытого 80-го порта вообще. Я подробно разбирал этот сценарий отдельно в статье про выпуск сертификата Let's Encrypt при закрытом порте 80 — там же способ снять с себя постоянную заботу о синхронизации сертификата между прокси и почтовыми протоколами, потому что при DNS-01 через встроенный клиент такой проблемы в принципе не возникает.
Если внешний ACME уже часть инфраструктуры и переделывать архитектуру ради одного mailcow нецелесообразно — тогда именно post-hook с явным перезапуском трёх контейнеров и есть правильное постоянное решение, а не разовая заплатка. Такой хук я закладываю сразу при настройке mailcow, вместе с остальными пунктами первичного разворачивания — например, когда переносим компанию на mailcow, весь процесс от миграции ящиков до финальной проверки TLS на всех протоколах я подробно описал в пошаговом регламенте переноса на mailcow. Отдельно на плановой проверке раз в квартал прогоняю те же три команды openssl s_client по IMAPS/SMTP/HTTPS на всех клиентских mailcow — это пять минут, которые снимают целый класс тикетов «не могу получить почту с телефона» после каждого продления.
- один mailcow за собственным доменом — обычно проще оставить встроенный ACME-клиент mailcow
- порт 80 закрыт периметром — используйте встроенный DNS-01, а не внешний ACME-клиент
- общий реверс-прокси на несколько сервисов — тогда внешний ACME оправдан, но обязателен post-hook
- регулярная проверка openssl s_client по трём протоколам — дешевле, чем разбор тикетов после каждого продления
Частые вопросы
Почему сертификат обновился в браузере, а в Outlook и на телефоне — нет?
HTTPS для веб-интерфейса обслуживает обратный прокси перед mailcow, а IMAPS (993) и SMTPS/STARTTLS (465/587) — напрямую контейнеры dovecot-mailcow и postfix-mailcow, которые читают отдельные файлы data/assets/ssl/cert.pem и key.pem. Обновление на прокси не долетает до этих файлов автоматически, если сертификатами занимается внешний ACME-клиент.
Как проверить срок действия сертификата отдельно на IMAP и SMTP, не открывая почтовый клиент?
Командой openssl s_client с указанием конкретного порта: openssl s_client -connect домен:993 для IMAPS, -connect домен:465 для SMTPS, openssl s_client -starttls smtp -connect домен:587 для SMTP с STARTTLS; openssl x509 -noout -dates на выходе покажет срок действия сертификата, отданного именно этим портом.
Куда именно нужно класть сертификат, если его выпускает внешний ACME-клиент?
Полную цепочку (сертификат плюс промежуточный CA) — в data/assets/ssl/cert.pem, соответствующий приватный ключ — в data/assets/ssl/key.pem внутри каталога mailcow-dockerized. Символические ссылки на файлы в другом каталоге документация использовать запрещает — только реальное копирование.
Какие контейнеры нужно перезапускать после замены сертификата вручную?
Три: postfix-mailcow, dovecot-mailcow и nginx-mailcow. Без перезапуска процессы внутри контейнеров продолжат использовать старый сертификат, загруженный в память при старте, даже если файл на диске уже обновлён.
Можно ли настроить это один раз и забыть, не копируя сертификат вручную каждые три месяца?
Да, через post-hook скрипт у ACME-клиента — у certbot это исполняемый файл в /etc/letsencrypt/renewal-hooks/deploy/, который автоматически запускается после каждого успешного продления (путь к сертификату — в переменной RENEWED_LINEAGE) и сам копирует свежие файлы плюс перезапускает три нужных контейнера.
Стоит ли вообще использовать внешний ACME-клиент вместо встроенного в mailcow?
Если mailcow — единственный сервис за доменом, проще оставить встроенный ACME: он сам всё синхронизирует без дополнительных скриптов. Внешний клиент оправдан, когда один и тот же прокси обслуживает ещё несколько сервисов и сертификатами удобнее управлять централизованно — но тогда post-hook обязателен.
Источники
- docs.mailcow.email — Advanced SSL — Проверено: SKIP_LETS_ENCRYPT=y + пересоздание acme-mailcow, пути data/assets/ssl/cert.pem и key.pem, «IMPORTANT: Do not use symbolic links», перезапуск docker restart $(docker ps -qaf name=…) для postfix/nginx/dovecot, проверочные openssl s_client на 587/465/143/993/443, ссылка на DNS-01. https://docs.mailcow.email/post_installation/firststeps-ssl/
- docs.mailcow.email — Reverse Proxy: post-hook для внешних ACME-клиентов — Проверен раздел «Optional: Post-hook script for non-mailcow ACME clients»: cp fullchain.pem/privkey.pem из /etc/letsencrypt/live/<домен>/ в data/assets/ssl/, docker restart ${postfix_c} ${dovecot_c} ${nginx_c}. https://docs.mailcow.email/post_installation/reverse-proxy/r_p/
- mailcow.email — Mooly 2026 (release-2026-07) — Проверена актуальность стека mailcow на 23.09.2026 для контекста версий (Nginx 1.30.3, Postfix 3.10.12) — обновление веб-прокси не связано автоматически с почтовыми TLS-портами ни в одной из текущих версий. https://mailcow.email/posts/2026/release-2026-07/
- mailcow community — IMAP connection on TLS (cert issue) — Проверен практический разбор случая, когда HTTPS работал, а IMAP на порту 993 сообщал о просроченном сертификате при использовании внешнего реверс-прокси. https://community.mailcow.email/d/4513-imap-connection-on-tls-cert-issue



