Як працює IMAP IDLE: push без опитування і чому з'єднання тихо помирає
IMAP IDLE подають як «магічний push», але насправді це довга TCP-сесія, яку по дорозі вбиває черга таймерів: NAT оператора, stateful-firewall, 30-хвилинний ліміт сервера і напіввідкриті з'єднання. Розбираємо протокол на рівні байтів, правило 29 хвилин, детектування мертвої сесії та реконект через QRESYNC без повного ресинку.
EvilMail Team20 липня 2026 р.11 хв читання
Чому polling — це біль
Уявіть поштовий клієнт без IDLE. Щоб дізнатися, чи прийшов лист, він мусить питати сервер сам: NOOP або STATUS INBOX (MESSAGES UNSEEN) кожні 15-30 секунд. Один клієнт — дрібниця. Тепер помножте.
10 000 активних скриньок, опитування раз на 30 секунд, і ви отримуєте стабільні ~333 запити за секунду тільки на перевірку «а чи є щось нове». З них 99.9% відповідей — «нічого не змінилося». Ви палите CPU на розбір команди, дискові lookup'и по індексу теки і TLS-оверхед — заради того, щоб раз за разом почути «ні».
Гірше з латентністю. Якщо ви опитуєте раз на 30 секунд, середня затримка доставки листа користувачу — половина інтервалу, 15 секунд. Хочете 2 секунди — опитуйте раз на 4 секунди і множте навантаження на сервер у 7 разів. Це глухий кут: або сервер лягає, або сповіщення повільні.
Саме звідси народився RFC 2177. Ідея проста: замість того щоб клієнт довбив сервер, хай сервер сам скаже клієнту, коли щось сталося. Але «просто» тут закінчується, і починається інженерна дисципліна тримання з'єднання.
Що насправді відбувається в сокеті
IDLE — не окремий магічний канал. Це команда, яку ви даєте всередині звичайної IMAP-сесії після того, як вибрали теку. Ось повний діалог, який можна відтворити руками через openssl:
Розберемо по кроках. Клієнт шле a3 IDLE. Сервер відповідає continuation-рядком + idling — це означає «я перейшов у режим, тепер слухай». З цього моменту сервер у будь-який момент шле untagged-відповіді в реальному часі: * 5 EXISTS (у теці тепер 5 повідомлень), * 1 EXPUNGE (повідомлення видалили), * 1 RECENT.
Деталь, на якій горять початківці: під час IDLE канал фактично однонаправлений. Єдине, що клієнту дозволено надіслати — рядок DONE (без тегу!), щоб вийти з режиму. Спробуєте всунути a4 FETCH посеред IDLE — отримаєте протокольну помилку. Спочатку DONE, дочекатися a3 OK IDLE terminated, і лише потім нові команди.
Друга пастка — що саме каже вам * 5 EXISTS. Це нова кількість повідомлень у теці, а не UID нового листа. EXISTS не несе жодної інформації про те, який саме лист прийшов. Щоб дізнатися деталі, після виходу з IDLE треба зробити явний запит:
a4 UID FETCH 5 (UID FLAGS ENVELOPE)
І навіть тут номер 5 — це sequence number, не стабільний UID. Якщо між вашим SELECT і EXISTS хтось видалив лист, номери зсунулися. Тому серйозні клієнти тримають мапу sequence→UID і акуратно її оновлюють по EXPUNGE.
Одне IDLE = одна тека
IDLE моніторить тільки поточну вибрану SELECT-теку. Одне з'єднання = одна тека під наглядом. Крапка.
Це має прямий наслідок для архітектури. Хочете push по INBOX плюс трьом папкам, куди сортувальні фільтри розкладають пошту? Це чотири окремі TCP-з'єднання, кожне зі своїм TLS-хендшейком, своїм LOGIN і своїм IDLE. Множимо на кількість користувачів онлайн — і впираємось у ліміти.
У Dovecot за замовчуванням mail_max_userip_connections = 10 — максимум IMAP-з'єднань з одного IP на користувача. Чотири теки × кілька пристроїв, і клієнт легко з'їдає ліміт та ловить відмову в новому з'єднанні. Gmail жорсткіший: ~15 одночасних IMAP-з'єднань на акаунт, перевищення повертає Too many simultaneous connections.
Формально проблему вирішує RFC 5465 NOTIFY — розширення, що дозволяє одним з'єднанням підписатися на події кількох тек. На папері красиво. На практиці підтримка серверами й досі нерівна, тому в проді ви або жертвуєте покриттям папок (моніторимо тільки INBOX), або свідомо мультиплексуєте пул з'єднань і рахуєте ліміти самі.
Черга таймерів: де вмирає з'єднання
Ось де закінчується теорія RFC і починається реальність. Ваше IDLE-з'єднання проходить крізь ланцюг посередників, і кожен має власний таймер, який хоче його вбити за неактивності.
NAT мобільного оператора. Це головний убивця. Оператори тримають запис у таблиці трансляції лише поки йде трафік. Типовий idle-timeout — 5-10 хвилин, у деяких CGNAT-конфігурацій ще агресивніше. Немає пакетів — запис викидається, і ваш * EXISTS уже нема куди маршрутизувати.
Stateful firewall / CGNAT. Та сама логіка на корпоративних і провайдерських шлюзах: сесія без пакетів вважається завершеною.
Сам сервер. RFC 2177 прямо дозволяє серверу розірвати IDLE після 30 хвилин неактивності. Це не баг, це буква стандарту.
Напіввідкрите з'єднання (half-open). Найпідступніше. Проміжний вузол зник, RST не дійшов до вашого боку. З точки зору вашого TCP-стеку сесія жива: сокет відкритий, read() спокійно блокується. А насправді на тому кінці вже нікого нема, і * EXISTS ніколи не прийде. Клієнт «слухає» мертву лінію годинами.
Проти цього працюють два щити. Перший — на клієнті: правило 29 хвилин. RFC радить не сидіти в IDLE безкінечно, а виходити (DONE) і перевидавати IDLE не рідше ніж раз на 29 хвилин, щоб гарантовано вкластися в серверний 30-хвилинний ліміт. Другий — на сервері: Dovecot з imap_idle_notify_interval = 2 mins сам шле діагностичний * OK Still here кожні дві хвилини. Цей трафік протягує запис крізь NAT і не дає таблиці трансляції протухнути.
Як помітити, що сесія мертва
Отримати * EXISTS легко. Справжня проблема IDLE — надійно помітити, що ви його вже не отримаєте. Мертва сесія не кидає виняток, вона просто мовчить, і наївний клієнт мовчить разом із нею.
Захист будується трьома шарами, і потрібні всі три.
Application-level watchdog. Найнадійніший. * OK Still here від Dovecot — це не сміття, це ваш пульс. Якщо ви знаєте, що keepalive приходить раз на 2 хвилини, поставте таймер, скажімо, на 5 хвилин (інтервал плюс запас на джитер). Прийшла будь-яка untagged-відповідь або keepalive — таймер скидається. Спрацював таймер — сесія підозріла, рвемо і реконектимось. Не питайте себе «а раптом ще прийде»; за протоколом пульс уже мав бути.
TCP keepalive на рівні ОС. Вмикається сокет-опцією SO_KEEPALIVE, але дефолти системи вбивчі для IDLE: net.ipv4.tcp_keepalive_time за замовчуванням 7200 секунд (2 години). Тобто ОС помітить розрив аж за дві години — задовго. Тюнимо для IDLE-воркерів:
bash
# /etc/sysctl.d/99-imap-idle.conf
net.ipv4.tcp_keepalive_time = 120 # перший пробник через 2 хв тиші
net.ipv4.tcp_keepalive_intvl = 30 # повтор кожні 30 с
net.ipv4.tcp_keepalive_probes = 4 # 4 невдалих = розрив
Це ловить half-open: якщо на тому кінці нікого, пробники не дійдуть, і ядро само закриє сокет із помилкою, замість того щоб read() висів вічно.
Ніколи не покладайтеся на те, що `read()` колись поверне помилку сам по собі. Без keepalive і без watchdog половинчасте з'єднання може висіти доти, доки хтось не спробує щось у нього записати. Ваш моніторинг має бути активним, а не реактивним.
Реконект без повного ресинку
Помітили розрив, перепідключились. Наївний клієнт тепер робить SELECT INBOX і UID FETCH 1:* (FLAGS), щоб «зрозуміти, що змінилося». На скриньці з 50 000 листів це б'є по серверу так само боляче, як polling, від якого ми тікали. І якщо реконекти масові (сервер моргнув, тисячі клієнтів повертаються разом) — це вже DoS по власній інфраструктурі.
Правильний шлях — CONDSTORE/QRESYNC (RFC 7162). Перед розривом зберігаємо два числа: UIDVALIDITY і HIGHESTMODSEQ. При відкритті теки просимо сервер віддати тільки дельту:
Сервер повертає лише повідомлення й зміни флагів із моменту вашого HIGHESTMODSEQ, а не всю теку. Один нюанс критичний: якщо UIDVALIDITY у відповіді не збігається зі збереженим — теку перебудували, всі UID більше не валідні, локальний кеш скидаємо повністю і робимо холодну синхронізацію. Це рідко, але ігнорування UIDVALIDITY призводить до тихого показу не тих листів.
І реконект робимо не в лоб, а з експоненційним backoff та джитером, щоб уникнути thundering herd:
спроба 1 → 1с спроба 2 → 2с спроба 3 → 4с
спроба 4 → 8с ... cap 60с + random(0..1000мс)
Джитер обов'язковий. Без нього після падіння сервера всі клієнти повертаються синхронно, кожні рівно 1, 2, 4 секунди, і кладуть сервер повторно щойно він встав.
Практика: IDLE на Node.js через imapflow
На інфраструктурі evilmail.pro IDLE-воркери крутяться на imapflow. Бібліотека сама керує циклом re-IDLE і keepalive, тому ваша робота — правильно реагувати на події й на розриви:
javascript
import { ImapFlow } from 'imapflow';
function connectIdle() {
const client = new ImapFlow({
host: 'mail.evilmail.pro',
port: 993,
secure: true,
auth: { user, pass },
// imapflow сам виходить з IDLE і перезаходить < 29 хв
socketTimeout: 5 * 60 * 1000,
});
let backoff = 1000;
client.on('exists', async (data) => {
// data.count — нова кількість, НЕ UID.
// НЕ робимо довгий FETCH прямо тут, поки активний IDLE:
// ставимо в чергу, imapflow сам вийде з IDLE під запит.
queue.push(data);
});
client.on('close', () => scheduleReconnect());
client.on('error', (err) => {
logger.warn({ err }, 'idle socket error');
scheduleReconnect();
});
function scheduleReconnect() {
const wait = Math.min(backoff, 60000) + Math.random() * 1000;
backoff *= 2;
setTimeout(connectIdle, wait);
}
(async () => {
await client.connect();
await client.mailboxOpen('INBOX');
backoff = 1000; // успішний конект — скидаємо backoff
await client.idle(); // тримає IDLE з авто-re-IDLE
})().catch(scheduleReconnect);
}
Головне правило, яке ламає продакшн: не запускайте довгий `FETCH` усередині активного IDLE. imapflow серіалізує команди, але якщо ви в обробнику exists синхронно тягнете тіла сотень листів, ви блокуєте вихід з IDLE, keepalive не встигає, і watchdog на тому кінці вважає вас мертвим. Складайте роботу в чергу і виконуйте поза IDLE.
Чек-лист перед продом
re-IDLE ≤ 29 хвилин — виходити з IDLE і заходити знову, щоб не впертися в серверний 30-хвилинний таймаут.
TCP keepalive 120-300 с замість дефолтних 7200 — тюнити tcp_keepalive_time/intvl/probes для IDLE-воркерів.
Application-level watchdog на очікування keepalive * OK Still here; немає пульсу вчасно — рвати й реконектити.
Окреме з'єднання на теку з обліком лімітів: Dovecot mail_max_userip_connections, Gmail ~15.
Backoff з джитером при реконекті: 1→2→4→8с, cap 60с, плюс випадкові мілісекунди.
QRESYNC замість повного fetch на реконекті; зберігати UIDVALIDITY + HIGHESTMODSEQ.
Перевіряти UIDVALIDITY — зміна означає скидання локального кешу.
EXISTS — це кількість, не UID; деталі тягнути окремим UID FETCH після виходу з IDLE.
Логувати причину кожного розриву (таймаут, RST, keepalive-miss) — без цього ви не знайдете, який саме таймер вас убиває.