Почему 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).secondsval detection = Duration.between(message.receivedAt, Instant.now()).secondslogger.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:

  1. Новое письмо и сдвиг UID записываются в базу. Уникальный ключ (ящик, UIDVALIDITY, UID) делает запись идемпотентной.

  2. Несколько воркеров забирают задачи из очереди и отправляют в Telegram.

  3. Ошибки делятся на временные (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.

  • Удаление данных. Одна команда удаляет все: ящики с паролями, настройки, очередь уведомлений. Сами письма бот не хранит, только недолгий кэш для кнопок под карточкой.

Итоги

Что я вынес из этой истории:

  1. IDLE — не гарантия. У каждого сервера своя задержка, и у Gmail она бывает в десятки секунд. Если скорость важна, IDLE нужно страховать периодической проверкой.

  2. NOOP не всегда обновляет состояние. Gmail может отдавать на NOOP старую картину ящика. Свежие данные дает только повторный SELECT/EXAMINE.

  3. Не логиньтесь в цикле. Повторный вход работает, но 240 логинов в час на ящик — прямой путь к блокировке. Одно постоянное соединение для подстраховки дает ту же скорость почти без входов.

  4. Сначала измерьте. Пока я не начал разделять время на «доставку» (Date → INTERNALDATE) и «обнаружение» (INTERNALDATE → сейчас), я чинил не то.

Сейчас письмо из Gmail приходит в Telegram в среднем за 8–10 секунд, из Mail.ru и Яндекса — почти мгновенно.

Весь код открыт: github.com/LightSmit/mail‑to‑telegram‑bot. Сам бот работает как @FromMailBot, если хотите посмотреть на результат вживую.

Источник: habr.com

0 0 голоса
Рейтинг новости
1
0
Подписаться
Уведомить о
0 комментариев