IMAP IDLE и push-уведомления: как на самом деле работает мгновенная почта
Инженерный разбор IMAP IDLE от handshake по RFC 2177 до боевых настроек Dovecot 2.3.16. Почему одно соединение = один ящик, как imap-hibernate спасает от OOM на десятках тысяч висящих TCP, и почему «мгновенная почта» на телефоне — это вообще не IDLE, а APNs/FCM поверх lib-push-notification.
EvilMail Team29 июля 2026 г.12 мин чтения
Poll убивает и сервер, и батарею
Клиент без IDLE не знает, что пришло письмо — он вынужден спрашивать. NOOP, STATUS INBOX (MESSAGES UNSEEN), снова NOOP, по кругу, каждые N секунд. Поставьте интервал в 60 секунд и посадите на сервер 10 000 клиентов — получите ~167 запросов в секунду фонового шума, из которых 99% возвращают «ничего нового». А средняя задержка доставки при этом всё равно равна половине интервала опроса: письмо, упавшее сразу после NOOP, будет ждать клиента 30 секунд.
IDLE (RFC 2177) переворачивает модель. Вместо «клиент дёргает сервер» — «сервер сам будит клиента, когда есть что сказать». Именно этот механизм даёт то самое ощущение мгновенной почты в Thunderbird и K-9: письмо появляется в списке за доли секунды после того, как MTA положил его в Maildir. На десктопе это работает ровно так, как задумано. На телефоне — нет, и это не баг клиента, а физика радиомодуля, к которой мы вернёмся ниже. Сначала — провод.
Как IDLE работает на проводе (RFC 2177)
IMAP IDLE и push: настройка Dovecot и мгновенная доставка почты — EvilMail Blog
IDLE — это не «волшебное постоянное соединение». Это короткая, строго определённая команда с одним разрешённым выходом. Последовательность такая: клиент выбирает ящик через SELECT INBOX, затем шлёт a IDLE. Сервер отвечает продолжением + idling и переходит в режим, где он имеет право слать клиенту untagged-ответы в любой момент, а клиент может отправить ровно одну вещь — DONE. Больше ничего. Никаких FETCH, никаких SEARCH посреди IDLE.
Когда приходит письмо, сервер будит клиента untagged-ответами:
* 4 EXISTS — в ящике теперь 4 письма (пришло новое);
* 2 EXPUNGE — сообщение удалено (например, с другого устройства);
* 3 FETCH (FLAGS (\Seen)) — изменились флаги.
Обратите внимание: EXISTS говорит только «стало больше», но не отдаёт содержимое. Клиент, увидев * 4 EXISTS, обязан выйти из IDLE через DONE и уже потом сделать FETCH, чтобы забрать новое письмо. IDLE — это будильник, а не доставщик.
Критичное правило эксплуатации — 29 минут. Сервер по стандарту вправе разорвать простаивающее IDLE-соединение через 30 минут. Поэтому корректный клиент обязан минимум раз в 29 минут сам послать DONE, получить a OK, и тут же снова войти в a IDLE. Это не опция — это условие выживания соединения.
После пере-IDLE клиенту не обязательно перечитывать весь ящик заново. Если сервер поддерживает CONDSTORE/QRESYNC (RFC 7162), клиент передаёт последний известный MODSEQ и получает только дельту изменений — это на порядок дешевле полной синхронизации при каждом цикле.
Ограничения, о которых не пишут в README
Главное ограничение IDLE, о которое спотыкаются все: IDLE следит только за одним выбранным ящиком. За тем, что в SELECT. Хотите мгновенные уведомления по INBOX, по папке «Работа» и по «Счетам» одновременно — открывайте три TCP-соединения и запускайте три независимых IDLE. Умножение начинается моментально: число соединений = число отслеживаемых папок × число устройств пользователя.
Формально проблему решает RFC 5465 NOTIFY — расширение, позволяющее мониторить несколько ящиков в рамках одного соединения. На бумаге. На практике в 2026 году клиентская поддержка NOTIFY близка к нулю: подавляющее большинство почтовых клиентов до сих пор льют по соединению на папку. Так что при проектировании сервера рассчитывайте на модель «соединение на папку», а не на красивый NOTIFY из RFC.
Остальные острые углы:
Полудуплекс. Пока клиент в IDLE, он не может параллельно сделать FETCH — сначала DONE. Отсюда микрозадержка между «узнал» и «забрал».
Нет приоритетов.EXISTS о спаме и о письме от начальника выглядят одинаково. Приоритизация — забота клиента после FETCH.
`EXISTS` без контекста. Сервер сообщает только новое количество, но не «от кого» и не «в какой папке» без отдельного запроса.
Настройка Dovecot: параметры, которые реально решают
Мы держим mailserver на evilmail.pro, и вот три настройки из /etc/dovecot/conf.d/20-imap.conf (Dovecot 2.3.16), от которых зависит, переживёт IDLE прод или нет. Все три в дефолтном конфиге закомментированы, то есть работают со значениями по умолчанию — и как минимум одно из этих умолчаний в проде вредно.
# /etc/dovecot/conf.d/20-imap.conf
protocol imap {
# Как часто Dovecot шлёт статус-обновление IDLE-клиенту, чтобы NAT
# и файрвол не сочли соединение мёртвым. Дефолт 2 mins — оставляем.
imap_idle_notify_interval = 2 mins
# Через сколько простоя в IDLE перенести соединение в лёгкий
# процесс imap-hibernate. ДЕФОЛТ 0 = ВЫКЛЮЧЕНО. Включаем!
imap_hibernate_timeout = 30 secs
}
# Потолок соединений на пользователя с одного IP. Дефолт 10.
# IDLE плодит по соединению на папку — при 4 папках x 3 устройства
# c одного домашнего IP (CGNAT!) вы упрётесь в лимит.
mail_max_userip_connections = 20
Арифметика, которая объясняет, почему это важно. Возьмите 10 000 активных пользователей, каждый держит IDLE на INBOX и ещё одной папке — это 20 000 висящих TCP-соединений. Без хибернации каждое такое соединение — это отдельный полноценный процесс imap. Двадцать тысяч процессов, каждый с рабочим набором в единицы мегабайт, — это легко десятки гигабайт RAM, съеденных соединениями, которые 99.9% времени просто молчат. Это прямая дорога к OOM-killer.
imap-hibernate: как держать 20k IDLE без 20k процессов
Спасение — imap-hibernate. Идея: когда IDLE-соединение простаивает дольше imap_hibernate_timeout, Dovecot передаёт файловый дескриптор сокета в один общий лёгкий процесс imap-hibernate и закрывает тяжёлый процесс imap. Сокет остаётся живым, клиент по-прежнему в IDLE и ничего не замечает, но память полноценного imap-процесса освобождена. Как только по соединению приходит активность (или сервер должен послать EXISTS), соединение «будится» обратно в нормальный imap-процесс.
Проверьте, что служба поднята, в /etc/dovecot/conf.d/10-master.conf:
# /etc/dovecot/conf.d/10-master.conf
service imap-hibernate {
unix_listener imap-hibernate {
mode = 0660
group = $default_internal_user
}
}
service imap {
# Потолок процессов imap. Под тысячи юзеров поднимайте:
process_limit = 4096
}
Один процесс imap-hibernate спокойно держит десятки тысяч сокетов, потому что на спящее соединение уходит буквально несколько десятков байт состояния вместо мегабайтов. Формулируя жёстко: без hibernate запускать IDLE в проде на тысячах пользователей — это заявка на OOM. Первым делом проверьте: imap_hibernate_timeout не должен быть 0.
Держим соединение живым сквозь NAT, файрвол и балансировщик
IDLE-соединение по определению минутами молчит. А всё, что стоит между клиентом и Dovecot, ненавидит молчащие TCP-сессии. Stateful-файрволы и conntrack выбрасывают запись о соединении по idle-таймауту (типично 300–3600 секунд для established TCP). CGNAT провайдера делает то же самое. L4-балансировщик перед сервером — тоже.
Коварство в том, что разрыв происходит молча. Балансер или файрвол просто забывает про соединение, пакеты в обе стороны начинают уходить в никуда, а клиент об этом узнаёт только когда попытается послать очередной DONE — и получит таймаут вместо a OK. Всё это время новые письма до клиента не доходят: «мгновенная почта» тихо сломалась.
Два рубежа обороны:
Прикладной keepalive Dovecot — тот самый imap_idle_notify_interval = 2 mins. Каждые две минуты сервер шлёт клиенту статус-строку, и это движение пакетов сбрасывает idle-таймеры на промежуточных узлах.
TCP keepalive на уровне ОС — подстраховка, если прикладного трафика мало.
Если перед Dovecot стоит HAProxy или nginx stream для терминации/проксирования 993 порта, поднимите таймауты клиента и сервера выше 30 минут — иначе балансер станет самым слабым звеном:
# haproxy.cfg — фрагмент для IMAPS
frontend imaps
bind :993
mode tcp
timeout client 1h
default_backend dovecot_imaps
backend dovecot_imaps
mode tcp
timeout server 1h
server dc1 127.0.0.1:9993 check
Настоящий push на мобильные — это НЕ IMAP IDLE
Теперь — главный миф. Галочка «Push» в iOS Mail не означает, что телефон держит постоянный IMAP IDLE к вашему серверу. Телефон физически не может себе этого позволить: держать открытый TCP-сокет и радиомодуль в готовности 24/7 — это выжатая за полдня батарея. Именно поэтому FairEmail и K-9 в режиме «постоянный IDLE» заметно сажают аккумулятор — они делают ровно то, чего мобильная ОС старается избежать.
Настоящий мобильный push идёт мимо вашего IMAP-соединения, через системный push-канал ОС. Архитектура такая: при доставке письма Dovecot через lib-push-notification дёргает драйвер уведомлений, тот шлёт HTTP-хук на push-шлюз, шлюз отправляет уведомление в APNs (Apple) или FCM (Google), радиомодуль телефона просыпается по системному пушу, и уже разбуженное устройство делает короткий IMAP-sync и снова засыпает. IMAP используется, но только на пару секунд и только по факту пуша.
Со стороны Dovecot включается это так — плагины notify и push_notification подключаются в mail_plugins, драйвер настраивается в 90-plugin.conf:
Важная оговорка про реальность 2026 года: публичный APNs для сторонних mailserver закрыт — Apple выдаёт push-топики только зарегистрированным почтовым приложениям (у Apple Mail — привилегированный доступ через провижининг). На практике для своего сервера это означает FCM, собственный push-шлюз с приложением-компаньоном, либо Dovecot Pro с готовым push-стеком. IDLE «в лоб» с телефона остаётся, но платите за него батареей.
Тестирование и отладка руками
IDLE проверяется голым openssl за минуту. Открываете сессию, логинитесь, входите в IDLE, из другой сессии (или другого клиента) кидаете себе письмо и смотрите, прилетит ли * N EXISTS:
bash
openssl s_client -connect mail.evilmail.pro:993 -crlf -quiet
a login [email protected] пароль
b select INBOX
c idle
# сервер отвечает: + idling
# ... из другого места шлём себе письмо ...
# прилетает: * 5 EXISTS
done
# сервер закрывает IDLE: c OK Idle completed.
Инспекция живых соединений на сервере — через doveadm:
bash
doveadm who # кто подключён, сколько соединений и pid на юзера
doveadm who -1 [email protected] # соединения конкретного ящика
doveadm proctitle # что делают процессы (idle / hibernate)
Если пользователи жалуются, что почта «отваливается» и приходит только после ручного обновления, вопрос один: клиент не пере-IDLE-ит или соединение режет посредник. Различить помогает tcpdump:
bash
tcpdump -i any -n port 993 and host <клиент>
Видите каждые две минуты обмен пакетами keepalive, а потом резкую тишину без FIN/RST — это молчаливый обрыв на файрволе/балансировщике (проверьте рост и вымывание записей в conntrack -L). Видите, как клиент сам шлёт DONE, но не возвращается в IDLE, — это баг клиента или упёртый mail_max_userip_connections.
Чеклист продакшн-настройки
Включить imap_hibernate_timeout = 30 secs — без этого IDLE на тысячах юзеров съест память и приведёт к OOM.
Проверить imap_idle_notify_interval ≤ 2 mins — прикладной keepalive против NAT и файрволов.
Поднять mail_max_userip_connections под реальное число папок × устройств (помните про CGNAT — много юзеров за одним IP).
Увеличить process_limit для service imap под ожидаемое число активных соединений.
Таймауты балансировщика (timeout client/timeout server) > 30 минут на порт 993.
Включить TCP keepalive на уровне ОС как второй рубеж.
Для мобильных — push-шлюз через APNs/FCM поверх lib-push-notification, а не расчёт на постоянный IDLE.
Мониторить число висящих соединений (doveadm who | wc -l) и вымывание записей conntrack.