Почему IMAP упирается в потолок на телефоне
Обычная сцена. Человек час ехал в поезде без сети, телефон вышел из туннеля, почтовый клиент проснулся и пытается показать свежий INBOX. Что при этом делает IMAP-клиент: SELECT INBOX, дожидается ответа, шлёт UID SEARCH, ждёт, UID FETCH флагов, ждёт — и так по каждой папке отдельно. Десятки round-trip'ов, каждый со своим TCP-туда-обратно. На LTE с RTT 80–150 мс это складывается в 2–3 секунды «залипшего» интерфейса, пока крутится спиннер.
А ещё IMAP держит соединение открытым. Чтобы получать пуш, клиент запускает команду IDLE — и для каждой папки, за которой он хочет следить, нужно отдельное живое TCP-соединение. Три-четыре папки под наблюдением — три-четыре сокета, которые радио телефона обязано поддерживать. Вот это и греет батарею: не «тяжёлый» трафик, а постоянно живущие соединения, не дающие модему уснуть.
Проблема не в том, что IMAP «старый». Проблема в его модели: stateful, chatty, per-folder. Протокол спроектирован под настольный клиент 1990-х, у которого есть стабильная сеть и который может позволить себе долгий разговор с сервером. Телефон живёт иначе: сеть рвётся, IP меняется при переходе с Wi-Fi на LTE, приложение убивают из фона. Вопрос, на который отвечает эта статья, простой: что если весь этот многошаговый диалог свернуть в один HTTP POST?
Анатомия JMAP: сессия, батчинг, back-references
JMAP — это не бинарная стейт-машина, а JSON поверх обычного HTTPS. Никаких SELECT/FETCH, никакого нумерованного состояния сессии на сервере. Клиент делает POST, получает JSON, всё.
Начинается всё с discovery. Клиент делает GET https://<host>/.well-known/jmap и получает Session-объект — карту всего аккаунта:
{
"capabilities": {
"urn:ietf:params:jmap:core": { "maxSizeUpload": 250000000 },
"urn:ietf:params:jmap:mail": {}
},
"accounts": { "u1": { "name": "[email protected]" } },
"primaryAccounts": { "urn:ietf:params:jmap:mail": "u1" },
"apiUrl": "https://mail.evilmail.pro/jmap",
"downloadUrl": "https://mail.evilmail.pro/download/{accountId}/{blobId}",
"uploadUrl": "https://mail.evilmail.pro/upload/{accountId}/",
"eventSourceUrl": "https://mail.evilmail.pro/jmap/eventsource",
"state": "s0"
}Дальше вся работа идёт через один эндпоинт — POST на apiUrl. В теле — массив methodCalls, где каждый вызов это тройка [имя_метода, аргументы, callId]. И вот здесь ключевое: несколько методов летят в одном запросе, а результат одного вызова можно подставить во второй прямо на сервере, не возвращаясь к клиенту. Это back-reference через синтаксис #:
{
"using": ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:mail"],
"methodCalls": [
["Mailbox/get", { "accountId": "u1", "ids": null }, "0"],
["Email/query", {
"accountId": "u1",
"filter": { "inMailbox": "INBOX" },
"sort": [{ "property": "receivedAt", "isAscending": false }],
"limit": 30
}, "1"],
["Email/get", {
"accountId": "u1",
"#ids": { "resultOf": "1", "name": "Email/query", "path": "/ids" },
"properties": ["threadId", "subject", "from", "receivedAt", "preview", "keywords"]
}, "2"]
]
}Разберём цепочку. Первый вызов забирает список папок. Второй ищет 30 последних писем в INBOX и возвращает их id. Третий берёт эти id (#ids ссылается на результат вызова "1", поле /ids) и вытягивает только нужные свойства — тему, отправителя, превью, флаги. Три метода, одна поездка на сервер. То, что в IMAP занимало десяток команд по каждой папке.
Стек стандартов, на который стоит опираться в 2026:
- RFC 8620 — JMAP Core (ноябрь 2019): сессия, батчинг,
/changes, blob upload/download. - RFC 8621 — JMAP for Mail:
Mailbox,Email,Thread,EmailSubmission. - RFC 8887 — JMAP over WebSocket: push и запросы через один сокет.
- RFC 9661 — JMAP for Sieve (сентябрь 2024): управление серверными фильтрами.
- RFC 9007 — JMAP MDN: уведомления о прочтении.
Все они — Proposed Standard. Это не черновик и не эксперимент.
Синхронизация по дельтам вместо полного ресинка
Вот где JMAP реально выигрывает у IMAP, и это важнее батчинга. У каждого типа объектов есть строка состояния — state. Клиент запоминает, на каком состоянии он остановился. После реконнекта он не тянет весь ящик заново — он шлёт старый state в Email/changes или Mailbox/changes:
["Email/changes", { "accountId": "u1", "sinceState": "s41" }, "0"]Сервер отвечает тремя списками — created, updated, destroyed — и новым newState. Всё. Клиент узнаёт ровно то, что изменилось за время офлайна, без гадания и без вычитывания заголовков всех папок.
Формально то же самое умеет и IMAP через CONDSTORE/QRESYNC (RFC 7162). На практике эта пара реализована неровно и в клиентах, и в серверах, а при смене UIDVALIDITY клиент обязан свалиться на полный ресинк папки — то есть перечитать всё. В JMAP модель дельт встроена в ядро, а не прикручена сбоку опциональным расширением.
Пуш устроен так же экономно. Вместо N соединений IDLE (по одному на папку) JMAP даёт один канал на весь аккаунт: либо EventSource (text/event-stream), либо WebSocket по RFC 8887. По этому каналу приходят компактные StateChange-объекты: «состояние Email изменилось на такое-то». Клиент видит, что что-то поменялось, и сам решает, когда сходить за дельтой. А на мобильных вообще можно не держать сокет — пуш доставляется через посредника, и приложение просыпается только по факту нового письма.
Что это даёт мобильному клиенту в цифрах
Сложим экономию по трём осям.
- Задержка интерфейса. Открытие ящика после офлайна: IMAP делает 15–30 round-trip'ов, JMAP — один POST. На RTT 120 мс это разница между 2–3 секундами спиннера и мгновенной отрисовкой.
- Батарея. Нет пула persistent
IDLE-соединений. Один push-канал на аккаунт вместо одного сокета на папку — модем реже держит радио включённым. - Трафик. gzip на HTTP плюс выборка только нужных свойств:
bodyValuesс усечением (maxBodyValueBytes), конкретные заголовки, а не всё письмо. Треды приходят готовымиThread-объектами — клиенту не нужно самому склеивать переписку поReferencesиIn-Reply-To.
Плюс архитектурный бонус: JMAP stateless, поэтому дружит с HTTP/2-мультиплексингом и балансировщиками — любой POST можно отправить на любой бэкенд, не привязываясь к «той самой» сессии, как в IMAP.
Кто реально говорит на JMAP в 2026 (честный расклад)
Серверная сторона зрелая. Stalwart (написан на Rust) — самый полный FOSS-стек: JMAP + IMAP + SMTP + Sieve в одном бинаре. Apache James давно умеет JMAP. Cyrus IMAP имеет JMAP-модуль. Fastmail — родина протокола, у них он в проде с 2019 года и служит референсом.
А вот и главный тезис, которого нет в 90% статей: в open-source Dovecot нативной поддержки JMAP нет. JMAP есть только в коммерческом OX/Dovecot Pro. Никакого «плагина, который прикрутит JMAP к вашему Dovecot CE», не существует. Если кто-то обещает вам это — он путает Dovecot Pro с Community Edition.
Узкое место — клиенты. Полноценно на JMAP говорят по сути только приложения Fastmail. Из инструментов есть mujmap (синхронизирует JMAP с notmuch/Maildir) и экспериментальная поддержка в aerc. Массовых мобильных JMAP-клиентов пока нет. Вывод трезвый: сервер готов, экосистема клиентов догоняет — и планировать миграцию нужно с оглядкой именно на это, а не на серверную часть.
Запуск JMAP рядом с Dovecot: как это делается правильно
Сразу развею опасный миф. Нельзя поднять два процесса — Dovecot и JMAP-сервер — и заставить их писать в один и тот же живой Maildir. Это прямой путь к порче индексов и гонкам по локам. Так не делают.
Рабочая схема на 2026 выглядит иначе. Stalwart поднимается как JMAP-эндпоинт со своим собственным хранилищем (RocksDB/FoundationDB или SQL+blob). Он делит с Dovecot общий каталог аутентификации — ту же самую SQL-базу mailserver с таблицами virtual_users/virtual_domains. nginx терминирует TLS и роутит /.well-known/jmap, /jmap и eventsource на Stalwart, а порт 993/IMAPS оставляет Dovecot'у для легаси-клиентов на время перехода.
Общий каталог аутентификации в конфиге Stalwart — та же база, что читает Dovecot:
[directory."sql"]
type = "sql"
[directory."sql".connect]
url = "mysql://mailuser:[email protected]/mailserver"
[directory."sql".query]
name = "SELECT email, password FROM virtual_users WHERE email = ?"Оба сервера проверяют пароли по одной таблице (схема Dovecot — PLAIN), пользователю не нужно ничего заводить заново. А вот nginx-локейшены. Тут важнейшая деталь — отключить буферизацию для SSE, иначе push будет копиться в буфере nginx и не доходить до клиента:
location /.well-known/jmap { proxy_pass http://127.0.0.1:8080; }
location /jmap {
proxy_pass http://127.0.0.1:8080;
proxy_http_version 1.1;
}
location /jmap/eventsource {
proxy_pass http://127.0.0.1:8080;
proxy_buffering off;
proxy_cache off;
proxy_set_header Connection '';
proxy_http_version 1.1;
proxy_read_timeout 3600s;
}Стратегически схема выглядит так: Stalwart становится точкой доставки (MDA) — входящая почта по SMTP/LMTP кладётся в его store, и только в него. Dovecot на переходный период остаётся read-only IMAP-фронтом для клиентов, которые ещё не умеют JMAP. При cut-over делается одноразовый импорт старого Maildir в Stalwart, и после того как легаси-клиенты переведены, Dovecot выводится. Двойной доставки в два хранилища сразу быть не должно ни секунды.
Проверка и отладка руками
Первое, что стоит проверить, — отдаётся ли Session-объект. Basic-auth здесь работает для быстрой диагностики:
curl -s -u [email protected]:'пароль' \
https://mail.evilmail.pro/.well-known/jmap | jq
curl -s -u [email protected]:'пароль' \
-H 'Content-Type: application/json' \
-d @batch.json \
https://mail.evilmail.pro/jmap | jq '.methodResponses'Где batch.json — тот самый Request-объект с Mailbox/get + Email/query + Email/get. Если .methodResponses вернул письма — цепочка nginx → Stalwart → SQL-директория живая.
Типовые грабли, на которые уходит время:
- CORS. Для веб-клиента нужны
Access-Control-Allow-OriginиAccess-Control-Allow-Headers, иначе браузер молча зарежет запросы. - TLS-сертификат. Он обязан покрывать autodiscovery-хост (
mail.<domain>), на котором лежит/.well-known/jmap, а не только основной домен. - Права хранилища. Каталоги store у нас исторически под
5000:5000— проверьте владельца, иначе Stalwart не запишет ни blob, ни индекс.
Чеклист перед продакшеном
- TLS-сертификат покрывает autodiscovery-хост (
mail.<domain>), где отдаётся/.well-known/jmap. /.well-known/jmapвозвращает 200 и валидный Session-объект без редиректов на страницу логина.proxy_buffering offиproxy_read_timeout ≥ 3600sна eventsource-локейшене.- CORS-заголовки настроены, если планируется веб-клиент.
- Единый каталог аутентификации Stalwart ↔ Dovecot: пароли читаются из одной таблицы
virtual_users. - Доставка (MDA) настроена ровно в один store — Stalwart, — двойной доставки нет.
- Права
5000:5000на каталогах хранилища, бэкап индексов и блобов работает. - Мониторинг: latency
POST /jmap
И одно трезвое предостережение напоследок. Не пытайтесь разделить один Maildir между двумя серверами вживую — это ломает индексы, а не экономит время. И помните, что настоящий ограничитель миграции на JMAP в 2026 — не сервер, который давно готов, а клиентская экосистема, которая пока догоняет. Считайте это при планировании сроков.


