Почему Gmail опаздывает по IMAP IDLE и как я сократил задержку с 44 до 8 секунд
Я делаю Telegram‑бота, который пересылает новые письма из почты в чат. Главное требование — скорость: если письмо приходит через минуту, проще открыть почту самому. С Mail.ru и Яндексом все заработало сразу, а Gmail присылал уведомления о новых письмах с опозданием до 44 секунд.
В статье расскажу, как я искал причину, почему первое рабочее решение оказалось опасным (240 входов в почту в час на каждый ящик) и чем все закончилось: одно постоянное соединение и около 8 секунд от отправки письма до уведомления в Telegram. В конце — еще несколько грабель IMAP, которые не описаны в туториалах.
Стек: Kotlin, корутины, Jakarta Mail (реализация Eclipse Angus), Ktor, SQLite, Docker Compose. Все замеры — на сервере с 1 ГБ памяти, по логам бота: это наблюдения на нескольких ящиках, а не строгий бенчмарк.
Как работает IMAP IDLE
У IMAP есть два способа узнать о новом письме. Можно раз в N секунд спрашивать сервер «что нового?» (polling), а можно отправить команду
IDLE
(RFC 2177): клиент держит соединение открытым, а сервер сам присылает
* 15 EXISTS
, когда в папке появилось письмо. Это и есть push для почты.
В Jakarta Mail IDLE выглядит как блокирующий вызов
IMAPFolder.idle()
, который возвращает управление, когда сервер что‑то сообщил. Первая версия слежения за ящиком выглядела примерно так (упрощено):
suspend fun watch(account: MailAccount) {
val store = session.getStore("imaps") as IMAPStore
runInterruptible(Dispatchers.IO) {
store.connect(account.host, 993, account.username, account.password)
}
val inbox = store.getFolder("INBOX") as IMAPFolder
runInterruptible(Dispatchers.IO) { inbox.open(Folder.READ_ONLY) }
while (currentCoroutineContext().isActive) {
// Блокируется, пока сервер не пришлет EXISTS или другое событие
runInterruptible(Dispatchers.IO) { inbox.idle(true) }
// Забираем письма с UID больше последнего обработанного
processNewMessages(account, inbox)
}
}
Несколько деталей, которые важны с самого начала:
- Новые письма ищутся по UID, а не по флагу \Seen: пользователь мог уже прочитать письмо с телефона. Последний UID и UIDVALIDITY папки хранятся в базе.
- Блокирующие вызовы идут через runInterruptible, иначе отмена корутины (пользователь удалил ящик) висит до таймаута сокета.
- Серверы рвут IDLE‑соединения раз в десятки минут, поэтому вокруг цикла нужен перезапуск с экспоненциальной паузой (5 с, 10 с, 20 с... до 10 минут). Иначе при неверном пароле бот превращается в переборщика паролей, и почта его блокирует.
Замеры: где теряется время
Чтобы не гадать, я разделил задержку на две части и логирую обе для каждого письма:
- доставка почтой — от заголовка Date (когда письмо отправили) до INTERNALDATE (когда его принял почтовый сервер получателя);
- обнаружение ботом — от INTERNALDATE до момента, когда бот увидел письмо.
val transport = Duration.between(message.sentAt, message.receivedAt).seconds
val detection = Duration.between(message.receivedAt, Instant.now()).seconds
logger.info("Email UID {} from {}: mail transport delay={} s, bot detection delay={} s", ...)
Первая часть от меня не зависит, вторая — целиком моя. Одна оговорка:
Date
ставит отправитель, и его часы могут спешить, поэтому отрицательные значения обрезаются до нуля.
С чистым IDLE картина получилась такая:
Ящик — Доставка почтой — Обнаружение ботом — Итого до Telegram
Mail.ru / list.ru — 0 с — 0 с — 1–6 с
Gmail — 1–2 с — до 40+ с — до 44 с
Сервер Gmail принимал письмо за секунду‑две, но сообщал об этом в открытое IDLE‑соединение с большим опозданием. Значит, проблема не в доставке, а в том, как и когда уже открытая IMAP‑сессия узнает о новом письме. А однажды Gmail не показывал новое письмо по IMAP вообще несколько минут: в логах есть случай с обнаружением через 251 секунду. Такое не лечится ничем на стороне клиента, но среднюю задержку сократить можно.
Попытка 1: NOOP по открытому соединению
Первая идея: раз в несколько секунд самому «толкнуть» сервер. Команда
NOOP
ничего не делает, но в ответ на нее сервер обязан прислать накопившиеся обновления папки, в том числе
EXISTS
. Нового входа в ящик для этого не нужно.
В Jakarta Mail самый простой способ прервать текущий
idle()
NOOP
— обратиться к папке из другого потока. Библиотека сама завершит IDLE (
DONE
), выполнит команду, а основной цикл проверит новые письма и снова уйдет в IDLE:
coroutineScope {
val noopJob = launch {
while (isActive) {
delay(5_000)
// Обращение к папке из другого потока прерывает IDLE и шлет NOOP
runInterruptible(Dispatchers.IO) { inbox.messageCount }
}
}
try {
while (isActive) {
runInterruptible(Dispatchers.IO) { inbox.idle(true) }
processNewMessages(account, inbox)
}
} finally {
noopJob.cancel()
}
}
На серверах, которые просто «засыпают» в IDLE, это помогает. А вот Gmail в той же сессии на
NOOP
отвечал тем же устаревшим состоянием папки: задержка по‑прежнему доходила до 40+ секунд. Похоже, что уже открытая сессия Gmail видит папку с задержкой, а свежий взгляд дает только новое открытие папки. Это моя гипотеза по наблюдениям, документации на этот счет я не нашел.
Попытка 2: контрольная проверка с новым входом
Раз свежая сессия видит письма сразу, я добавил рядом с IDLE вторую корутину: раз в 15 секунд она заново входит в ящик, открывает «Входящие», забирает письма после последнего UID и выходит.
suspend fun watch(account: MailAccount) = supervisorScope {
launch { runIdleLoop(account) } // push, как раньше
launch {
while (isActive) {
delay(15_000)
checkWithFreshLogin(account) // connect + SELECT INBOX + FETCH + logout
}
}
}
Чтобы IDLE и контрольная проверка не прислали одно письмо дважды, обработка ящика идет под
Mutex
на аккаунт, а последний UID сдвигается в базе сразу после постановки письма в очередь. Кто увидел письмо первым, тот его и отправил.
Результат по скорости сразу стал хорошим: три тестовых письма на Gmail пришли в Telegram через 10, 12 и 14 секунд после отправки вместо прежних 44.
Но у решения есть цена, которую на пяти ящиках не видно. Каждая проверка — это полноценный вход: TLS‑рукопожатие,
LOGIN
SELECT
LOGOUT
- 1 ящик: 4 входа в минуту, 240 в час, почти 6 тысяч в сутки;
- 200 ящиков: около 13 входов в секунду с одного IP‑адреса.
Для почтового сервиса это выглядит как перебор паролей или сломанный клиент. Лимиты на частоту входов почтовики не публикуют, но получить временную блокировку IP или письма «подозрительный вход» у всех пользователей сразу не хотелось. Решение работало, но не масштабировалось.
Решение: один вход, но свежий SELECT
Ключевое наблюдение: свежий список писем дает не новый вход, а новое открытие папки (
SELECT
EXAMINE
). А открыть папку заново можно в уже авторизованном соединении сколько угодно раз.
Так что контрольная проверка теперь держит свое постоянное соединение с ящиком. Раз в 15 секунд она открывает «Входящие», забирает новое и закрывает папку, но не соединение. Вход происходит один раз, и повторяется, только когда сервер сам разорвет соединение.
private suspend fun runSafetyPollLoop(account: MailAccount) {
var store: IMAPStore? = null
try {
while (currentCoroutineContext().isActive) {
delay(15_000)
try {
store = checkOverPersistentConnection(account, store)
} catch (e: Exception) {
closeQuietly(store)
store = null // в следующий раз войдем заново (с нарастающей паузой)
}
}
} finally {
closeQuietly(store)
}
}
private suspend fun checkOverPersistentConnection(account: MailAccount, current: IMAPStore?): IMAPStore {
if (current != null && current.isConnected) {
try {
checkInbox(account, current)
return current
} catch (e: Exception) {
// Сервер мог тихо закрыть соединение: не ждем следующего круга
closeQuietly(current)
}
}
val fresh = connectStore(account) // единственное место, где происходит LOGIN
checkInbox(account, fresh)
return fresh
}
private suspend fun checkInbox(account: MailAccount, store: IMAPStore) {
val inbox = store.getFolder("INBOX") as IMAPFolder
runInterruptible(Dispatchers.IO) { inbox.open(Folder.READ_ONLY) } // свежий SELECT/EXAMINE
try {
processNewMessages(account, inbox)
} finally {
runCatching { if (inbox.isOpen) inbox.close(false) }
}
}
Два момента, на которые стоит обратить внимание:
- У соединения проверки короткий таймаут чтения (60 секунд), а у IDLE‑соединения длинный (25 минут). Иначе «зависшее» соединение проверки молча блокирует корутину на десятки минут.
- isConnected не гарантирует, что сервер еще держит соединение. Поэтому при ошибке на старом соединении бот сразу входит заново и проверяет еще раз в том же цикле.
Итоги всех трех подходов для Gmail:
Подход — До Telegram — Входов в час на ящик
IDLE + NOOP — до 44 с — около 1
+ проверка с новым входом раз в 15 с — 10–14 с — 240
+ проверка по постоянному соединению — около 8–10 с — около 2
В первом же тесте после выкатки лог показал
mail transport delay=2 s, bot detection delay=6 s
: бот даже быстрее, чем с входом на каждую проверку. На всякий случай старое поведение можно вернуть одной переменной окружения, если какой‑то сервер поведет себя иначе.
Еще три грабли IMAP, о которых мало пишут
IMAP SEARCH ищет не так, как ждет пользователь
В приложении можно искать письма по теме и отправителю. Первая версия просто отправляла серверу
SEARCH OR SUBJECT "..." FROM "..."
, и первый же тест нашел две проблемы:
- письмо с темой «22222» не находилось по запросу «222»: серверы часто ищут по целым словам, а не по подстроке;
- в Gmail не находилось ничего по русским словам.
Решение вышло гибридным. Последние 2000 писем папки бот читает одним
FETCH
ENVELOPE
FLAGS
(это быстро, тексты писем не скачиваются) и ищет подстроку сам. Для более старых писем остается серверный
SEARCH
. Строки перед сравнением нормализуются:
internal fun normalizeForSearch(value: String): String =
value.lowercase()
.replace('ё', 'е') // ё -> е: "ещё" находится по "еще"
.replace(Regex("\\s+"), " ")
.trim()
Хостинг может закрыть SMTP
Я сделал кнопку «Ответить»: текст из Telegram уходит письмом с ящика пользователя. Локально все работало, а на сервере летел таймаут соединения. Выяснилось, что многие VPS‑хостинги блокируют исходящие порты 465 и 587 против спама, а разблокируют по запросу и часто за деньги. Проверить можно за секунду до того, как писать код:
timeout 5 bash -c '</dev/tcp/smtp.mail.ru/465' && echo open || echo blocked
У меня порты оказались закрыты, и функция теперь включается флагом в конфиге: кнопка появляется, только если сервер реально может отправлять почту.
Уведомление не должно потеряться
Если бот забрал письмо из ящика и сдвинул последний UID, а потом Telegram ответил ошибкой или сервер перезапустился, письмо потеряно навсегда. Поэтому между почтой и Telegram стоит очередь‑outbox в SQLite:
- Новое письмо и сдвиг UID записываются в базу. Уникальный ключ (ящик, UIDVALIDITY, UID) делает запись идемпотентной.
- Несколько воркеров забирают задачи из очереди и отправляют в Telegram.
- Ошибки делятся на временные (429, 5 xx, сеть — повтор с нарастающей паузой, для 429 — строго по retry_after) и постоянные (403 «бот заблокирован» — больше не пытаемся).
И еще один трюк для скорости: карточка письма уходит сразу из заголовков (от кого, тема, дата), а когда письмо скачается целиком, бот редактирует ее и добавляет вложения. Большое вложение не задерживает само уведомление.
Безопасность: чужие пароли от почты — большая ответственность
Бот хранит доступ к чужим почтовым ящикам, поэтому безопасность тут не опция. Что сделано:
- Только пароли приложений. Gmail, Яндекс и Mail.ru и так не пускают сторонние программы по основному паролю. Пароль приложения пользователь может отозвать в любой момент, не меняя основной. И вводится он не в чат с ботом, а в Mini App.
- Шифрование в базе. Пароли хранятся в SQLite, зашифрованные AES-256-GCM со случайным IV для каждой записи. Ключ лежит только в переменной окружения, не в базе и не в бэкапах. Украденная база сама по себе паролей не дает. В toString() модели ящика пароль замаскирован, чтобы не утечь в логи случайно.
- Проверка подписи Telegram. Каждый запрос из Mini App несет initData, подписанный HMAC‑SHA256 от токена бота. Сервер проверяет подпись и свежесть auth_date и берет ID пользователя только оттуда. Подставить чужой ID и посмотреть чужие ящики нельзя.
- Защита от SSRF. Пользователь сам указывает IMAP‑сервер, а значит, может ввести 127.0.0.1, 169.254.169.254 или имя соседнего Docker‑контейнера и «прощупать» сервер бота изнутри. Перед подключением имя резолвится, и если хоть один адрес частный, link‑local, loopback или из диапазона CGNAT, ящик не принимается. Порт — только 993.
- Удаление данных. Одна команда удаляет все: ящики с паролями, настройки, очередь уведомлений. Сами письма бот не хранит, только недолгий кэш для кнопок под карточкой.
Итоги
- IDLE — не гарантия. У каждого сервера своя задержка, и у Gmail она бывает в десятки секунд. Если скорость важна, IDLE нужно страховать периодической проверкой.
- NOOP не всегда обновляет состояние. Gmail может отдавать на NOOP старую картину ящика. Свежие данные дает только повторный SELECT/EXAMINE.
- Не логиньтесь в цикле. Повторный вход работает, но 240 логинов в час на ящик — прямой путь к блокировке. Одно постоянное соединение для подстраховки дает ту же скорость почти без входов.
- Сначала измерьте. Пока я не начал разделять время на «доставку» (Date → INTERNALDATE) и «обнаружение» (INTERNALDATE → сейчас), я чинил не то.
Сейчас письмо из Gmail приходит в Telegram в среднем за 8–10 секунд, из Mail.ru и Яндекса — почти мгновенно.
Кстати, хотим поделиться находкой! Нашли отличный VPS который работает прямо в центре города, сами уже успели протестировать и остались довольны. Посмотреть можно здесь: moremore.pro