Я делаю 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:

  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, если хотите посмотреть на результат вживую.