
Mailcow у нас появился, когда почтовых доменов стало больше трёх. Сотрудники пишут и с внутреннего домена, по работе с нашими контрагентами, и с официального, для международной переписки.
Держать почту у того же Яндекса или Mail.ru по деньгам приемлемо, но за каждую функцию сверх тарифа, без которой в компании не обойтись, нужно доплачивать. Подключить AD, дать администратору зайти в чужой ящик, делать резервные копии писем: всё это дополнительный тариф и дороже. Зато такие сервисы набиты фишками: облако, мессенджер, фильтры в почте, из которых можно слепить чуть ли не CRM для бедных.
А тут ещё прилёт по дата‑центру Яндекса, и часть писем потерялась без возможности восстановления.
С этого и начался переезд на свой почтовый сервер. Первой переехала внутренняя корпоративная почта, к ней подключили AD, чтобы не заводить ящики вручную и не возиться с паролями. Когда IP прогрелся в антиспам‑базах и у него появилась репутация, перенесли и основные домены.
И вот тут начались стенания сотрудников, привыкших к Яндексу, Mail.ru и Gmail. Интерфейс SOGo «не такой», «стрёмный». Где мои фильтры, у меня пропала подпись, а ещё письма не отправляются, когда я загружаю туда файлы. Я посмотрел, а там эксель на 90 мегабайт. Увеличение лимита вложений на нашей стороне не спасло: принимающая сторона отказывается принимать письма с большими вложениями. Попытки объяснить, что так делать нельзя, разбивались о «а вот раньше всё работало».
Пришлось разбираться, как люди решают отправку больших файлов из почты. Находил статью, где к почте прикручивали Nextcloud, но мне это показалось сложным. Больше импонировал подход с хранением файлов, у которых есть срок жизни. Заодно он закрывает вопрос огромного архива писем: тяжёлые файлы больше не оседают в ящиках.
Требования получились такие:
загружать файлы может только сотрудник, который вошёл в почту, и никаких отдельных логинов и паролей — иначе им никто не будет пользоваться;
скачать может любой, у кого есть ссылка, без регистрации;
не трогать код самого Mailcow, чтобы не бояться
update.sh;всё описано в файлах и поднимается на другом сервере без ручного шаманства.
Дальше описано то, к чему пришли в итоге. По дороге были и ошибки: часть находилась при ручном тестировании, часть всплывала из отзывов сотрудников. Расписывать каждую не буду, только итоговые решения и то, на что стоит обратить внимание, если будете повторять.
Почему Pingvin Share X
На роль хранилища нужен был лёгкий self‑hosted файлообменник со ссылками и сроком жизни. Выбор пал на Pingvin Share: один контейнер, нормальная страница скачивания, срок жизни ссылок, загрузка частями.
С ним есть нюанс: оригинальный Pingvin Share автор заархивировал летом 2025 года и сам рекомендует форк Pingvin Share X. Форк живой, выходят релизы с патчами безопасности, но держит его один человек. Версия образа зафиксирована (v2.1.0), а обновляться стоит только после чтения релиз‑ноутов: Gateway опирается на внутренний API Pingvin, а он может поменяться.
Проблема в том, что Pingvin ничего не знает про почтовые ящики. Заводить в нём аккаунт на каждого сотрудника или поднимать OIDC с Keycloak ради одной функции не хотелось. Поэтому схема такая:

Проверка входа — маленький PHP‑скрипт, который читает сессию Mailcow и говорит Nginx «свой» или «чужой».
Gateway — сервис на Python (Starlette), который принимает файл от сотрудника и отдаёт его в Pingvin от имени одной служебной учётной записи. Заодно считает лимиты и ведёт журнал «кто что загрузил».
Pingvin хранит файлы и отдаёт страницу скачивания. Снаружи у него открыто только скачивание.
Gateway и Pingvin живут в отдельном docker-compose.yml рядом с Mailcow и соединены с его Nginx общей Docker‑сетью. Mailcow про них знает только одно — имя сети в docker-compose.override.yml:
services: nginx-mailcow: networks: mailshare: networks: mailshare: external: true
Почему не засунуть всё в override самого Mailcow? Тогда update.sh и docker compose down в каталоге почты начинают трогать файлообменник, а проблема в файлообменнике мешает подняться почте. По отдельности спокойнее.
Авторизация без второго логина
Идея простая: страница загрузки живёт на том же адресе, что и почта (/mailshare/), а перед каждым запросом Nginx через auth_request спрашивает у PHP, есть ли у браузера живая сессия Mailcow. Кука на том же домене, браузер её и так отправит. Второго логина нет.
Здесь два момента.
Первое: кука MCSESSID есть у всех, даже у анонима. Mailcow заводит PHP‑сессию на любой странице, в том числе на странице входа. Значит, проверять надо не наличие куки, а то, что лежит в сессии.
Второе: штатный prerequisites.inc.php для этого не подходит. Этот файл поднимает весь интерфейс Mailcow: подключается к Redis и MySQL, стучится в контейнер dockerapi (и отвечает 500, если тот недоступен), проверяет схему БД, минифицирует CSS и JS. А auth_request вызывается на каждую часть файла: отправка на 300 МБ кусками по 10 МБ означает тридцать полных инициализаций Mailcow.
Нужна только логика из sessions.inc.php, и то не целиком: он создаёт сессию анониму, обновляет LAST_ACTIVITY и вдобавок умеет пускать по заголовку X-API-Key с правами администратора. Поэтому проверка своя, и сессию она только читает:
<?php require_once __DIR__ . '/inc/vars.inc.php'; if (file_exists(__DIR__ . '/inc/vars.local.inc.php')) { include_once __DIR__ . '/inc/vars.local.inc.php'; } function deny() { http_response_code(401); exit; } // ID сессии берём из куки сами, чтобы анониму не создалась новая $sid = $_COOKIE[$SESSION_NAME] ?? ''; if (!preg_match('/^[A-Za-z0-9,-]{22,256}$/', $sid)) deny(); ini_set('session.use_cookies', 0); session_name($SESSION_NAME); session_id($sid); if (!@session_start(['read_and_close' => true])) deny(); $s = $_SESSION; if (empty($s['mailcow_cc_username'])) deny(); // не вошёл if (($s['mailcow_cc_role'] ?? '') !== 'user') deny(); // не почтовый ящик if (isset($s['dual-login'])) deny(); // админ под чужим ящиком if (!empty($s['mailcow_cc_api'])) deny(); // вход по API-ключу if (($s['SESS_REMOTE_UA'] ?? null) !== ($_SERVER['HTTP_USER_AGENT'] ?? '')) deny(); if (time() - ($s['LAST_ACTIVITY'] ?? 0) > $SESSION_LIFETIME) deny(); header('X-Mailshare-User: ' . $s['mailcow_cc_username']); http_response_code(204);
Проверки SESS_REMOTE_UA и LAST_ACTIVITY повторяют то, что делает сам Mailcow, иначе можно пройти с уже протухшей сессией. Хорошая новость: работа в SOGo сессию продлевает сама, потому что все запросы SOGo идут через auth_request /sogo-auth-verify, то есть через тот же PHP. А в свежих версиях форма входа SOGo и вовсе перебрасывает на форму Mailcow, так что сессия есть у всех.
Кусок Nginx (кладётся в data/conf/nginx/site.mailshare.custom, Mailcow сам подключает такие файлы внутрь сайта почты):
location = /mailshare-auth { internal; fastcgi_pass php-fpm-mailcow:9002; include fastcgi_params; fastcgi_param SCRIPT_FILENAME /web/mailshare-auth.php; fastcgi_pass_request_body off; fastcgi_param CONTENT_LENGTH ""; } location ^~ /mailshare/api/ { auth_request /mailshare-auth; auth_request_set $mailshare_user $upstream_http_x_mailshare_user; resolver 127.0.0.11 valid=30s; set $gw http://mailshare-gateway:8000; proxy_pass $gw; proxy_set_header X-Mailshare-User $mailshare_user; # затирает подделку от клиента client_max_body_size 12m; proxy_request_buffering off; }
Ещё две вещи, которые надо учесть.
Порт PHP‑FPM. В шаблонах Mailcow встречаются и
9001, и9002. Нужен9002— пул веб‑интерфейса, с теми же настройками сессий. Проще всего посмотреть готовый конфиг:docker compose exec nginx‑mailcow grep ‑n ‑B6 fastcgi_pass /etc/nginx/nginx.conf
returnсрабатывает раньшеauth_request.returnвыполняется на фазе rewrite, до проверки доступа, поэтому location сreturnответит кому угодно, включая анонима. Всё, что закрыто проверкой, должно проксировать запрос или передавать его в FastCGI.
Gateway и внутренний API Pingvin
У Pingvin нет описанного публичного API для загрузки, есть только то, чем пользуется его же фронтенд. Поэтому способ старый и надёжный: открываем интерфейс через SSH‑туннель, F12 → Network, загружаем файл на пару сотен мегабайт и смотрим, что уходит. В версии 2.1.0 загрузка выглядит так:
POST /api/auth/signIn → кука access_token (JWT на 15 минут) POST /api/shares {"id", "expiration": "180-days", "size", ...} POST /api/shares/<id>/files/upload-init {"id": <uuid файла>, "name", "totalChunks"} POST /api/shares/<id>/files?id=..&name=..&chunkIndex=N&totalChunks=M (сырые байты) POST /api/shares/<id>/complete → 202, ссылка начинает работать
Шага upload-init в описаниях первой версии нет, он появился во второй. Так что сверяйтесь с трафиком своей версии, а не со старыми гайдами.
Gateway — это несколько сотен строк на Starlette и страничка с драг‑н-дропом. Браузер режет файл на части и шлёт их в Gateway, Gateway проверяет владельца и лимиты и перекладывает части в Pingvin. Имя пользователя он берёт только из заголовка, который поставил Nginx, поэтому портов наружу у него нет. Все изменяющие запросы принимаются только с Origin почтового домена, чтобы чужой сайт не мог загрузить что‑то от имени вошедшего сотрудника.
Особенности API, которые надо учесть.
Размер части — не ваше дело. Pingvin не хранит номер следующей части, он вычисляет его из размера уже принятого и своей настройки share.chunkSize. Резать надо ровно так, как настроено в Pingvin (по умолчанию 10 000 000 байт), иначе вторая же часть считается «не по порядку». Gateway читает её из GET /api/configs. Зато на часть не по порядку Pingvin честно отвечает 400 с полем expectedChunkIndex, и на этом легко сделать докачку после обрыва связи.
403 вместо 401. Токен служебной учётной записи живёт 15 минут. Когда он истекает, Pingvin при создании share отвечает не «401, войдите заново», а «403, нельзя».
Поэтому Gateway читает exp из JWT и обновляет токен за минуту до конца, а на 401 и 403 один раз входит заново и повторяет запрос. Если тестируете что‑то с токенами, гоняйте дольше, чем живёт токен.
Secure‑кука по HTTP. Если в Pingvin указан HTTPS‑адрес, кука access_token приходит с флагом Secure, и HTTP‑клиент внутри Docker‑сети её честно не отправляет. Поэтому Gateway вынимает токен из Set-Cookie сам и кладёт в заголовок Cookie руками.
И ещё одно наблюдение: фронтенд Pingvin по умолчанию генерирует коды ссылок длиной 8–10 символов. Для ссылок, которые уходят наружу и живут полгода, этого маловато, так что Gateway создаёт share со случайным ID из 20 символов — API это позволяет.
Публичное скачивание: белый список в Nginx
Скачивание живёт на отдельном имени (допустим, files.example.com), но обслуживает его тот же Nginx из Mailcow: конфиг кладётся в data/conf/nginx/*.conf, а сертификат выпускает сам Mailcow — достаточно дописать имя в ADDITIONAL_SAN в mailcow.conf. Отдельное имя нужно и для репутации: если кто‑то когда‑нибудь раздаст через сервис что‑то нехорошее, в блок‑лист попадёт не почтовый домен.
Наружу открыто только то, что нужно странице скачивания. Список я снова снял с реального трафика в приватном окне, а всё остальное отвечает 404:
location ~ ^/(s|share)/[A-Za-z0-9_-]+/?$ { limit_except GET { deny all; } proxy_pass $pingvin; } location /_next/ { limit_except GET { deny all; } proxy_pass $pingvin; } location ~ ^/api/shares/[A-Za-z0-9_-]+$ { limit_except GET { deny all; } proxy_pass $pingvin; } location ~ ^/api/shares/[A-Za-z0-9_-]+/metaData$ { limit_except GET { deny all; } proxy_pass $pingvin; } location ~ ^/api/shares/[A-Za-z0-9_-]+/token$ { limit_except POST { deny all; } proxy_pass $pingvin; } location ~ ^/api/shares/[A-Za-z0-9_-]+/files/[A-Za-z0-9-]+$ { limit_except GET { deny all; } proxy_buffering off; # не складывать гигабайты на диск Nginx limit_rate 10m; # чтобы одно скачивание не съело весь канал почты proxy_pass $pingvin; } location = /sw.js { return 404; } location / { return 404; } # вход, админка, загрузка — мимо
Обратите внимание на resolver 127.0.0.11 и set $pingvin http://mailshare-pingvin:3000 в начале server‑блока. Если написать имя контейнера прямо в proxy_pass, Nginx резолвит его при старте, и стоит Pingvin упасть — не поднимется весь nginx-mailcow, то есть веб‑интерфейс почты. С переменной почта живёт, а сайт скачивания просто отдаёт 502.
Две строки в этом списке не очевидны.
metaData. Без него файлы по одному скачиваются, а «скачать всё» архивом вечно показывает «Загрузка готовится. Повторите попытку через несколько минут». Страница через metaData спрашивает, готов ли архив, получает 404 и считает, что не готов. Ошибки в консоли при этом нет.
sw.js. Это service worker из PWA фронтенда Pingvin. Он пытается закешировать workbox-*.js и главную страницу, которые снаружи закрыты, и сыплет ошибками в консоль. Странице скачивания он не нужен, поэтому отдаём явный 404: увидев его, браузер сам удаляет уже установленный воркер.
Проверка, что лишнего наружу не торчит:
curl -s -o /dev/null -w "%{http_code}\n" -X POST https://files.example.com/api/auth/signIn # 404 curl -s -o /dev/null -w "%{http_code}\n" -X POST https://files.example.com/api/shares # 404
Кнопка в SOGo и вставка в письмо
Сервис, до которого надо идти по отдельному адресу, сотрудники забудут через неделю. Поэтому нужна кнопка прямо в окне письма, рядом со скрепкой.
У Mailcow для этого есть штатный способ: файл data/conf/sogo/custom-sogo.js подключается в SOGo через SOGoUIAdditionalJSFiles, и обновления его не трогают. В нём я и держу свой блок между двумя строками‑метками:
// ---- Mailshare: кнопка «Отправить большие файлы ссылкой» ---- (function () { ... })(); // ---- конец блока Mailshare ----
Кнопку я не вшиваю в шаблоны SOGo, а ищу на странице. MutationObserver следит за DOM, и когда открывается окно письма (md-dialog или страница с #/new, #/reply и так далее), скрипт находит кнопку со значком вложения и ставит рядом свою, с cloud_upload.
Куда ставить блок и какую иконку искать
Блок стоит в начале custom-sogo.js. В штатном файле Mailcow есть строка с CKEDITOR.addCss(...), а в SOGo 5.12 объекта CKEDITOR уже нет: строка падает с ошибкой, и всё, что ниже, браузер не выполняет. Свой код обёрнут так, чтобы он сам никого не ронял.
Скрепка в окне письма SOGo 5.12 нарисована иконкой file_upload, в других версиях это attach_file. При этом attach_file встречается и в списке писем как признак вложения, поэтому скрипт ищет оба имени и берёт только значки внутри button.
Окно поверх письма, а не новая вкладка
Кнопка открывает страницу загрузки не в новой вкладке, а в iframe поверх того письма, из которого её нажали. У людей бывает открыто несколько черновиков, и с отдельной вкладкой ссылку легко вставить не в то письмо. Скрипт запоминает, какое окно было источником.
Страница загрузки живёт на том же домене, что и почта, поэтому iframe и SOGo общаются через postMessage с проверкой origin и source:
iframe сообщает
mailshare:state(идёт загрузка или нет), чтобы крестик спрашивал подтверждение;по кнопке «Вставить в письмо» приходит
mailshare:insertс HTML‑блоком, текстовой версией и готовым PDF как объектFile;обратно уходит
mailshare:insertedс тем, что получилось.
Вставка в CKEditor 5
В SOGo 5.12 стоит CKEditor 5, а большинство подсказок в интернете про четвёртую версию и CKEDITOR.instances. Пятую версию можно достать только через DOM‑элемент редактора:
var ed = ctx.querySelector('.ck-editor__editable').ckeditorInstance; var frag = ed.data.toModel(ed.data.processor.toView(html)); ed.model.insertContent(frag, position);
Позиция вставки вычисляется явно, а не берётся из курсора. Иначе блок заменит выделенный текст, а вторая вставка встанет на место первой. Блок идёт в конец письма, а в ответе или пересылке перед цитатой. Цитата в модели CKEditor 5 это элемент blockQuote, а над ним обычно абзац вида «12 марта Иван Петров написал(а):». Если абзац перед цитатой кончается двоеточием, блок встаёт перед ним.
Для CKEditor 4 и для писем в простом тексте оставлены запасные пути: setData и дописывание в textarea.
Абзацы, а не таблица
Блок со ссылкой состоит из обычных абзацев с переносами строк: заголовок, список файлов с размерами, ссылка «Скачать файлы» и дата, до которой она работает. Таблица выглядит аккуратнее, но в редакторе её неудобно править и удалять, а абзацы правятся как обычный текст.
Зачем PDF‑вложение
Письмо только со ссылкой выглядит у получателя как письмо без вложений: скрепки нет, и ссылку легко пропустить. Поэтому Gateway генерирует маленький PDF (reportlab, шрифт DejaVu, чтобы кириллица не превратилась в квадраты) со списком файлов и кнопкой‑ссылкой, а скрипт прикрепляет его к письму.
Прикреплять приходится так же, как это сделал бы человек. У SOGo есть input[type=file] с атрибутом nv-file-select (это angular‑file‑upload), и он реагирует на событие change:
var dt = new DataTransfer(); dt.items.add(file); input.files = dt.files; input.dispatchEvent(new Event('change', { bubbles: true }));
После этого скрипт пару секунд ждёт, пока имя файла появится в окне письма, и только тогда считает, что всё получилось. Если нет, страница загрузки показывает кнопку «Скачать PDF», чтобы прикрепить его руками.
PDF называется download-files.pdf: латиница надёжнее отображается в разных почтовых клиентах. Имя меняется в .env.
Чтобы обновления Mailcow ничего не сломали
Все мои изменения на стороне Mailcow живут в четырёх файлах, и три из них Mailcow вообще не знает:
Файл | Что делает |
|---|---|
|
|
| сайт скачивания |
| проверка сессии |
| кнопка, свой блок внутри штатного файла |
С первыми тремя всё спокойно. А вот custom-sogo.js при обновлении Mailcow может приехать новым, и блок исчезнет. Проверять это руками после каждого update.sh никто не будет.
Поэтому в репозитории лежат эталонные версии всех четырёх файлов и скрипт check-mailcow.sh. Он:
сравнивает каждый файл с эталоном через
cmpи кладёт эталон, если файл пропал или отличается;в
custom-sogo.jsищет блок по меткам и заменяет только его, остальное содержимое не трогает; если меток нет, ставит блок в начало файла;если найдена только одна метка из двух, ничего не делает и выходит с ошибкой: лучше разобраться руками, чем испортить файл;
подставляет домен из
.envв шаблон сайта скачивания;перезапускает
nginx-mailcowилиsogo-mailcow, только если что‑то реально поменял.
Метки начинаются с //, поэтому в sed используется разделитель |, а не /. Метка ищется без привязки к началу строки: в скопированном файле в самом начале может оказаться невидимый символ.
Скрипт идемпотентный, поэтому его можно просто поставить в cron раз в сутки и после обновления:
17 4 * * * cd /opt/mailshare && ./check-mailcow.sh >> /var/log/mailshare-check.log 2>&1
От изменений в самом SOGo скрипт не спасёт: другой редактор, другие имена иконок, другое поле вложений. На такой случай в блоке есть функция mailshareDiag(). Открываете окно письма, в консоли браузера набираете mailshareDiag() и видите таблицу: найден ли диалог письма, какой редактор, сколько полей для файлов и есть ли среди них nv-file-select, появилась ли кнопка. После обновления SOGo стоит первым делом посмотреть её.
Цифры, ограничения и что не сделано
Текущие настройки у нас такие:
Что | Значение |
|---|---|
Одна отправка, все файлы вместе | 300 МБ |
Файлов в одной отправке | до 100 |
Срок ссылки | 180 дней |
После истечения файлы лежат ещё | 30 дней, на случай «ой, мне очень надо» |
На сотрудника в сутки | 10 ГБ |
Одновременных отправок у одного человека | 3 |
300 МБ взялись не с потолка. Почти всё, что люди отправляют, это сканы, проекты и видео с совещаний, и в 300 МБ это укладывается. А если кто‑то попросил больше, это повод спросить, точно ли это должно идти по почте.
Одна вещь, о которой стоит знать заранее: если в отправке несколько файлов, Pingvin в фоне собирает zip для кнопки «скачать всё» и хранит его рядом. Получается, что многофайловая отправка занимает на диске почти вдвое больше. Считайте место с этим запасом.
Все эти цифры задаются в .env, и одна переменная MAX_SHARE_BYTES попадает и в Gateway, и в конфиг Pingvin. Если задавать лимиты по отдельности, легко получить ситуацию, когда Gateway пропускает файл, а Pingvin отклоняет его с невнятной ошибкой. Сам конфиг Pingvin генерируется из .env прямо в docker-compose.yml через configs: content: (нужен Docker Compose 2.23 или новее), так что настройки в веб‑интерфейсе Pingvin руками не трогаются вообще.
Что пока не сделано:
S3. Pingvin умеет хранить файлы в S3, но в v2 при этом файлы ходят напрямую между браузером и бакетом по подписанным ссылкам. Загрузка частями через Gateway и белый список в Nginx тогда устроены совсем иначе, и это отдельная работа. Пока хватает локального диска.
Антивирус. Хочется прогонять загруженное через ClamAV, который в Mailcow и так есть. Не дошли руки.
Удаление ссылки самим сотрудником. Если ссылка ушла не тому человеку, сейчас надо писать администратору, и он удаляет отправку в админке Pingvin.
Почтовые клиенты. Кнопка есть только в веб‑почте. Тем, кто сидит в Outlook или Thunderbird, остаётся открыть
/mailshare/в браузере (войдя в почту), скопировать блок и скачать PDF. Работает, но неудобно.
Про нагрузку: у нас около 50 человек, и сервис этого почти не замечает. Gateway однопроцессный, части файлов он не держит в памяти, а сразу пересылает в Pingvin. Узкое место, если оно будет, это канал сервера, поэтому скачивание ограничено limit_rate, чтобы большой файл не мешал работе почты.
Вместо заключения
В итоге у нас получилось то, что хотели с самого начала: сотрудник пишет письмо как обычно, жмёт облако рядом со скрепкой, кидает файлы, и через минуту в письме есть ссылка и PDF‑вложение. Никаких новых логинов, сторонних облаков и вопросов «а куда загрузить». Mailcow при этом остался штатным, а всё своё лежит в одном репозитории и восстанавливается одной командой.
Самое долгое во всей истории было не написать код, а выяснить, как ведут себя чужие системы: что Pingvin отвечает на просроченный токен, почему SOGo не выполняет мой скрипт, где в CKEditor 5 конец письма. Если будете повторять, держите под рукой F12 и вкладку Network, они сэкономят вам больше времени, чем любая документация.
Репозиторий с конфигами, Gateway, скриптом и инструкцией для сотрудников: ссылка на GitHub
Интересно, как эту проблему решают у вас. Nextcloud рядом с почтой, отдельный файлообменник, просто подняли лимит вложений до 50 МБ и смирились? Расскажите в комментариях.
