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 вообще не знает:

Файл

Что делает

data/conf/nginx/site.mailshare.custom

/mailshare/ внутри сайта почты

data/conf/nginx/mailshare-files.conf

сайт скачивания

data/web/mailshare-auth.php

проверка сессии

data/conf/sogo/custom-sogo.js

кнопка, свой блок внутри штатного файла

С первыми тремя всё спокойно. А вот 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 МБ и смирились? Расскажите в комментариях.