Я делаю 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 и Яндекса — почти мгновенно.
Весь код открыт: github.com/LightSmit/mail‑to‑telegram‑bot. Сам бот работает как @FromMailBot, если хотите посмотреть на результат вживую.

Комментарии 0