Привет! Меня зовут Илья. Два года назад я присоединился к работе над мобильным клиентом IMAP. Ранние версии приложения долго загружали список писем и тратили большое количество трафика для обновления ящика. Встал вопрос об оптимизации работы с протоколом и о возможностях этого протокола вообще. О протоколе я не знал ничего и погрузился в чтение документации. Оказывается, все это время клиент использовал протокол напролом и совсем не учитывал особенности реализации. Эти особенности помогли ускорить загрузку почты в 2 — 3 раза. О том что такое IMAP и какие есть фишки для его оптимизации дальше в моей статье.

Я не буду погружаться в протокол слишком глубоко. Статья скорее из разряда «Хотел бы я прочитать эту статью два года назад». Гуру IMAP вряд ли найдут для себя новую информацию. Статья опирается на описание протокола из документа RFC 3501.

Подключение к серверу


IMAP — протокол с состояниями. Для меня это было открытием, до этого я не видел и не работал с такими протоколами. Рассмотрим схему работы с сервером. 


Пойдем по порядку, а главное, с примерами. Для начала нужно создать соединение с сервером. Для этого воспользуемся библиотекой openSSL.

openssl s_client -connect imap.server.com:993 -crlf 

Отлично, соединение установлено и можно наблюдать ответ OK со строчкой, которая начинается с ответа CAPABILITY

OK [CAPABILITY IMAP4rev1 LITERAL+ SASL-IR LOGIN-REFERRALS ID ENABLE IDLE  SPECIAL-USE AUTH=PLAIN AUTH=LOGIN]

По каждому из CAPABILITY существует удобная шпаргалка, где со ссылками на RFC выписаны все возможные значения CAPABILITY. Например, IMAP4rev1 говорит клиенту о том, что сервер работает по стандарту IMAP4, а IDLE сигнализирует о том, что можно подписаться на изменения, происходящие в ящике.

Авторизация на сервере


После подключения к серверу нужно зайти в свой ящик. Делается это с помощью команды LOGIN

a1 LOGIN email pass

Так, стоп, логин я понимаю а а1 это что такое? — Возможно спросите вы. А это тэг команды. В интересах клиента тэги должны быть разные, так как ответ прилетает с тем же тегом, что и запрос, а значит его можно сопоставить для парсинга между командами. Также сервер может вернуть ответ со звездочкой в начале, как например * OK, это  называется untagged response. В основном, такой ответ возвращается для команд, которые ожидают в ответе несколько сущностей, например LIST. 

Запрос списка папок


Чтобы запросить список писем в папке нужно сначала эти папки узнать. Делается это командой LIST. Эта команда возвращается список папок на сервере.

A2 LIST «» *
* LIST (\HasNoChildren \Trash) «/» Trash
* LIST (\HasNoChildren \Sent) «/» Sent
* LIST (\HasNoChildren \Drafts) «/» Drafts
* LIST (\HasNoChildren \Junk) «/» Junk
* LIST (\HasNoChildren) «/» INBOX
A2 OK List completed (0.001 + 0.000 + 0.001 secs).

Первый параметр в команде — namespace.  Если сервер поддерживает namespace, то его значения можно запросить с помощью запроса NAMESPACE. Стандартный namespace выглядит как пустая строка. Далее в дело вступает параметр wildcards. С его помощью мы можем сказать серверу какие папки нам нужно вернуть. Например мы можем получить: ветку дерева папок, только корни, или вообще все, как в примере выше. Лучше так не делать, потому что кто знает сколько у пользователя папок в ящике. Авторы протокола рекомендуют использовать «%» — в таком случае вы получите все папки верхнего уровня из ящика. 

Из ответа мы понимаем что это untagged-ответ где каждая строчка — это ваши папки в ящике. Сначала идут флаги, по которым мы читаем метаинформацию папки, например, в примере у всех папок нет потомков и некоторые папки специального назначения (такие как Trash, Junk и др.). Дальше идет символ с разделителем папок. Этот символ используется для вложенных папок. Например для потомка папки Trash имя выглядело бы как «Trash/New Folder». После всех папок сервер вернет нам ОК с тегом, который мы присвоили команде и временем выполнения этой команды.  

Выбор папки


Далее по схеме, мы должны выполнить выбор папки, из которой подтянем наши сообщения. Делается это с помощью команды SELECT

4 SELECT INBOX
* FLAGS (\Answered \Flagged \Deleted \Seen \Draft $Forwarded $MDNSent)
* OK [PERMANENTFLAGS (\Answered \Flagged \Deleted \Seen \Draft $Forwarded $MDNSent \*)] Flags permitted.
* 16337 EXISTS
* 2 RECENT
* OK [UNSEEN 6037] First unseen.
* OK [UIDVALIDITY 1532079879] UIDs valid
* OK [UIDNEXT 17412] Predicted next UID
* OK [HIGHESTMODSEQ 21503] Highest
4 OK [READ-WRITE] Select completed (0.015 + 0.000 + 0.014 secs).

При выборе папки возвращается вся информация о ней. Пойдем по порядку.

  • Ответ с флагами, которые разрешены внутри папки для писем.  
  • Ответ с флагами который клиент может изменить навсегда
  • Ответ с количеством писем в папке
  • Ответ с количеством recent писем, то есть тех, которые мы получили между выборами папок
  • Ответ с количеством непросмотренных писем

Ну и пока остановимся на этом. Остальная информация нам пока не нужна.

Запрос писем


Теперь самое интересное — запрос писем. Здесь нужно быть крайне осторожным, особенно на мобильных клиентах. Согласитесь, вряд ли захочется при входе в приложение получать тысячи сообщений от сервера к себе в базу. Мало того, нет смысла загружать всё письмо целиком, так как это может быть нецелесообразно для отображения, например, списка всех писем. Допустим, чтобы быстро показать пользователю письма, мы запросим только «конверт». В этом конверте мы хотим увидеть: отправителя, получателя, тему письма и дату отправки. Будем загружать 10 первых сообщений.

5 FETCH 16337:16327 (ENVELOPE)

Через двоеточие перечисляется отрезок номеров писем, которые мы хотим получить, а в скобках то, что мы хотим из этих писем прочитать, в данном случае конверт письма.

Ответ приведу в сокращенном виде:

* 16334 FETCH (ENVELOPE ("Sat, 07 Sep 2019 23:07:48 +0000" "Hello from Fabric.io" (("Fabric" NIL "notifier" "fabric.io")) (("Fabric" NIL "notifier" "fabric.io")) (("Fabric" NIL "notifier" "fabric.io")) ((NIL NIL "me" "me@mail")) NIL NIL NIL "<5d7438441b07c_2d872ad30967b9646405c6@answers-notifier2012.mail>"))

Понятно, что ничего не понятно. А всё дело в том, что формат конверта диктуется RFC 2822. Его я не буду рассматривать в данной статье. В этом конверте есть вся необходимая информация: дата получения письма, тема письма, отправитель, получатель и даже messageId. Его клиенты используют для отображения цепочки писем.

Итак, мы смогли показать пользователю базовую информацию о письме, а как же тело?
Мы можем сразу скачать всё тело письма, независимо от его размеров, это конечно недолго но тем не менее затратно по сети и памяти. Кстати делается это всё той же командой FETCH 

6 FETCH 16337:16327 (BODY[]) 

Попробуйте на своих входящих такую команду, и вы поймете что я имел в виду под «затратно», даже с 10 сообщениями мы получаем достаточно объемный ответ с абсолютно всей информацией о письме. Кстати о ней.

Часто ли вы скачивали исходник письма в любом известном вам клиенте, чтобы посмотреть как он выглядит в первозданном виде? Если нет, то давайте распотрошим тестовое письмо. В него я добавил картинку прямо в письмо и картинку как вложение. Сохраним его в формате eml, и затем откроем любым текстовым редактором. В зависимости от клиента, вы получите разные исходники письма, но в целом они будут похожи. 

Начнем с заголовка письма:

Return-Path: <myemail>
Delivered-To:myemail
Received: from localhost (localhost [127.0.0.1])
	byimap.server.com (imap.server.com) with ESMTP id 6C2BE2A0363
	for <myemail>; Sun,  8 Sep 2019 23:41:29 +0300 (MSK)
X-Virus-Scanned: amavisd-new at imap.server.com
Received: from imap.server.com ([127.0.0.1])
	by localhost ( imap.server.com [127.0.0.1]) (amavisd-new, port 10026)
	with ESMTP id abx8HQQT_k5A for <myemail>;
	Sun,  8 Sep 2019 23:41:29 +0300 (MSK)
Mime-Version: 1.0
Date: Sun, 08 Sep 2019 20:41:28 +0000
Content-Type: multipart/mixed;
 boundary=»--=_Part_722_554093397.1567975288»
Message-ID: <9e4e3872e603eac2c20f26bb1d65548d>
From: "Me" <myemail>
Subject: Hey, Habr!
To: myemail
X-Priority: 3 (Normal)

В заголовке письма описана вся метаинформация, от кого, кому, когда, тип контента письма, тема и приоритет письма. Поле boundary указывает на границу письма.

Дальше поймете, что это значит.

----=_Part_722_554093397.1567975288
Content-Type: multipart/related;
 boundary=»--=_Part_583_946112260.1567975288»
----=_Part_583_946112260.1567975288
Content-Type: multipart/alternative;
 boundary=»--=_Part_881_599167713.1567975288»
----=_Part_881_599167713.1567975288
Content-Type: text/plain; charset=«utf-8»
Content-Transfer-Encoding: quoted-printable
----=_Part_881_599167713.1567975288
Content-Type: text/html; charset=«utf-8»
Content-Transfer-Encoding: quoted-printable
<!DOCTYPE html><html><head><meta http-equiv=3D"Content-Type" content=3D"t=
ext/html; charset=3Dutf-8" /></head><body><div data-crea=3D"font-wrapper"=
 style=3D«font-family: XO Tahion; font-size: 16px; direction: ltr»> <img =
src=3D"cid:jua-uid-q1nz1guinitrcfd3-1567975257318"><br><br><div></div> <b=
r> </div></body></html>
----=_Part_881_599167713.1567975288--
----=_Part_583_946112260.1567975288
Content-Type: image/jpeg; name=«2018-09-04 22.46.36.jpg»
Content-Disposition: inline; filename=«2018-09-04 22.46.36.jpg»
Content-ID: <jua-uid-q1nz1guinitrcfd3-1567975257318>
Content-Transfer-Encoding: base64

Каждый boundary является обычной границей части письма. Они начинаются с двух дефисов «--». Закрывающая граница имеет эти два дефиса ещё и в конце. Подробнее описано в RFC1341

Это можно назвать основной частью письма, здесь описаны части письма и их MIME-типы.

Про MIME-типы
MIME-тип это медиа тип, который был описан в MIME (Multipurpose Internet Mail Extensions) чтобы описывать типы содержимого внутри email сообщения. 

  • multipart/mixed говорит нам о том что письмо имеет смешанную структуру, то есть разные части писем могут быть разными представлениями той или иной информации. 

  • multipart/related говорит клиенту, что дальше части будут связаны, и их нельзя показывать отдельно, 

  • multipart/alternative говорит о том, что это два разных представления одной сущности, например, либо это text/plain либо text/html, тут уж клиент волен брать нужное представление. 


У нас здесь простого текста нет, поэтому логичнее брать html-представление. В этом html-представлении как раз находится картинка, с параметром Content-Disposition: inline, то есть она находится непосредственно в теле письма, а не в приложенных документах.

Ссылка на эту картинку не совсем простая. Она описывается параметром Content-ID, который равен jua-uid-q1nz1guinitrcfd3-1567975257318. Это ссылка на следующую часть письма — картинку, которая закодирована в base-64. Чтобы сэкономить нервы, я не стал включать весь base-64 код

Последняя часть письма имеет вид 

----=_Part_722_554093397.1567975288
Content-Type: image/png; name=«2018-07-02 11.08.23 pm.png»
Content-Disposition: attachment; filename=«2018-07-02 11.08.23 pm.png»
Content-Transfer-Encoding: base64

у которого уже Content-Disposition не inline, как у изображения выше, а attachment. Это изображение как раз должно пойти в панель вложенных файлов, оно кстати тоже закодировано в base-64 и имеет большой размер. Тут становится ясно что не стоит лишний раз грузить всё тело письма, если мы хотим показать только базовую информацию. 

Вернемся к протоколу


После работы над письмами нужно закрыть выбранную папку и попрощаться с сервером. Чтобы закрыть папку, нам нужно ввести команду CLOSE. Да, вот так просто


7 CLOSE
7 OK Close completed (0.001 + 0.000 secs).

Кстати, если вы работали с консолью параллельно со мной, и читали статью, то могло произойти не очень приятное событие, сервер мог закрыть ваше соединение по таймауту. Это совершенно нормально, и у каждого сервера таймаут свой, например, у нас это 30 минут. 
Поэтому рекомендуется в фоне делать команду NOOP

1 NOOP
1 OK NOOP completed (0.001 + 0.000 secs).

Она буквально ничего не делает, но позволяет держать соединение без таймаута сколько нам нужно. Если в данный момент у вас выбрана какая либо папка, NOOP может работать как периодический запрос изменений этой папки 

1 NOOP
* 16472 EXPUNGE
* 16471 EXPUNGE
* 16472 EXISTS
* 1 RECENT
1 OK NOOP completed (0.004 + 0.000 + 0.003 secs).

Здесь в ответе нас оповещают о двух удаленных сообщениях, об одном новом и о том, что в данной папке количество сообщений равно 16 472.

Замечу также, что работать можно только с одной выбранной папкой, параллельной работы здесь нет.

Ну и под конец закроем сессию с сервером и будем с ним прощаться.

8 LOGOUT
* BYE Logging out
8 OK Logout completed (0.001 + 0.000 secs).

Видим грустный untagged ответ BYE, а значит пора заканчивать работу.

Быстрая синхронизация с CONDSOTORE и QRESYNC


Для слежения за изменениями в ящике в выбранной папке можно использовать операцию NOOP. Но что делать, если мы хотим узнать что изменилось в папке, пока мы работали с другой? Самый очевидный вариант — перебрать все письма в локальном хранилище, будь то кэш или база данных, и сравнить с тем, что вернет сервер. С одной стороны, это действительно решение, и на некоторых серверах оно будет буквально единственно верным. С другой стороны, мы же хотим показывать письма настолько быстро, насколько вообще позволяет протокол. Благо наш сервер поддерживает такие расширения протокола как CONDSTORE и QRESYNC, которые были добавлены в RFC7162. Первый добавляет к сообщению и папке специальное 63-битное число, называемое mod-sequence, которое увеличивается при каждой операции над этим письмом. К папке добавляется самый высокий mod-sequence среди всех сообщений. В итоге при каждом подключении к папке на сервере, который поддерживает CONDSTORE мы легко можем узнать, поменялось что-либо или нет, просто сравнивая значения mod-sequence у локальной и серверной папки.

Кроме этого в этом расширении добавляются дополнительные параметры для команды STORE и FETCH — CHANGEDSINCE mod-sequence и UNCHANGEDSINCE mod-sequence, которые позволяют выполнять операцию если mod-sequence переданных сообщений больше и меньше данного соответственно.  Посмотрим на примере.

FETCH 17221:17241 (UID) (CHANGEDSINCE 0)
* OK [HIGHESTMODSEQ 22746] Highest
* 17222 FETCH (UID 18319 MODSEQ (22580))
* 17223 FETCH (UID 18320 MODSEQ (22601))
* 17224 FETCH (UID 18324 MODSEQ (22607))
* 17225 FETCH (UID 18325 MODSEQ (22604))
* 17226 FETCH (UID 18326 MODSEQ (22608))
* 17227 FETCH (UID 18327 MODSEQ (22614))
* 17228 FETCH (UID 18328 MODSEQ (22613))
* 17229 FETCH (UID 18336 MODSEQ (22628))
* 17230 FETCH (UID 18338 MODSEQ (22628))
* 17231 FETCH (UID 18340 MODSEQ (22628)
* 17232 FETCH (UID 18341 MODSEQ (22628))
* 17221 FETCH (UID 18318 MODSEQ (22583))

Я сэмулировал ситуацию, при которой мы заходим в ящик и ничего о нём до этого не знали, то есть наш локальный mod-sequence равен 0. Как видите сервер возвращает нам вообще все сообщения которые есть в ящике, так как до этого мы не получали ничего, и ничего не знаем о ящике. В ответ на запрос UID писем с CHANGEDSINCE приходит также и untagged-ответ OK c HIGHESTMODESEQ который мы сейчас сохраним, и для каждого сообщения свой MODSEQ

Проведем какие-нибудь операции с ящиком: добавим новые письма, поменяем флаги. Сделаем новый запрос но уже с предыдущим mod-sequence

1 fetch 17221:* (UID FLAGS) (CHANGEDSINCE 22746)
* 17267 FETCH (UID 18378 FLAGS () MODSEQ (22753))
* 17270 FETCH (UID 18381 FLAGS (\Seen) MODSEQ (22754))
* 17271 FETCH (UID 18382 FLAGS () MODSEQ (22751))
* 17273 FETCH (UID 18384 FLAGS () MODSEQ (22750))

и мы уже видим разницу, вместо вывода 20 старых и новых сообщеий которые только пришли (звездочка в 17221:* означает взять письма с номера 17221 до максимально возможного) нам приходят письма, чьи MODSEQ больше чем предыдущий указанный. Это достаточно хорошо помогает синхронизировать папку, в которой мы какое то время не были и получать как бы слепок измененных писем, вместо перебора всех возможных.

Казалось бы, куда ещё лучше? Но QRESYNC делает операцию синхронизации ещё быстрее, он позволяет указывать параметры MODSEQ и известные нам UID сообщений прямо во время выбора папки. Давайте объясню на примере. Для начала QRESYNC нужно включить коммандой ENABLE 

1 ENABLE QRESYNC
* ENABLED QRESYNC
1 OK Enabled (0.001 + 0.000 secs).
1 SELECT INBOX (QRESYNC (0 0))
* FLAGS (\Answered \Flagged \Deleted \Seen \Draft $Forwarded $MDNSent)
* OK [PERMANENTFLAGS (\Answered \Flagged \Deleted \Seen \Draft $Forwarded $MDNSent \*)] Flags permitted.
* 17271 EXISTS
* 0 RECENT
* OK [UNSEEN 17241] First unseen.
* OK [UIDVALIDITY 1532079879] UIDs valid
* OK [UIDNEXT 18385] Predicted next UID
* OK [HIGHESTMODSEQ 22754] Highest
1 OK [READ-WRITE] Select completed (0.001 + 0.000 secs).

так как мы ничего не знали о папке до этого, сервер нам возвращает только информацию о папке, без слепка её изменений. Допустим мы спросили первые двадцать сообщений и запомнили их UID а также HIGHESTMODESEQ. Выходим из папки, отправляем себе сообщение, удаляем сообщение, меняем флаги и возвращаемся с прошлой информацией о папке

1 CLOSE
1 OK Close completed (0.001 + 0.000 secs).
1 SELECT INBOX (QRESYNC (1532079879 22754 18300:18385))
* FLAGS (\Answered \Flagged \Deleted \Seen \Draft $Forwarded $MDNSent)
* OK [PERMANENTFLAGS (\Answered \Flagged \Deleted \Seen \Draft $Forwarded $MDNSent \*)] Flags permitted.
* 17271 EXISTS
* 0 RECENT
* OK [UNSEEN 17241] First unseen.
* OK [UIDVALIDITY 1532079879] UIDs valid
* OK [UIDNEXT 18386] Predicted next UID
* OK [HIGHESTMODSEQ 22757] Highest
* VANISHED (EARLIER) 18380
* 17269 FETCH (UID 18383 FLAGS () MODSEQ (22757))
* 17271 FETCH (UID 18385 FLAGS () MODSEQ (22755))
1 OK [READ-WRITE] Select completed (0.001 + 0.000 secs).

И вот уже при выборе измененной папки мы сразу же получаем слепок изменений, в виде ответа VANISHED (EARLIER) для сообщений, которые были удалены, и FETCH для сообщений, которые были добавлены или изменены. Теперь стало ещё проще синхронизировать папку, если пользователь давно в неё не заходил. Это очень крутой способ, если у вас локально в кэше хранится кучу сообщений, и вы не хотите сверять их с сообщениями на сервере.

Первый параметр этого запроса это UIDVALIDITY, который по сути используется для проверки того, что uid которые ты получил ранее не изменились в папке. Такое может быть, если сервер от сессии к сессии меняет uid у всех сообщений или папка была удалена и на месте неё была создана папка с таким же именем.

Второй параметр это известный нам HIGHESTMODSEQ и последнее это интервал известных UID, они могут быть написаны как через двоеточие, если интервал непрерывный, так и через запятую.

Заключение


Я столкнулся на своем примере с ситуацией, когда незнание предметной области приводит к неправильной и неоптимальной работе приложения. Этой статьей я не покрыл абсолютно все возможные варианты использования протокола. Но надеюсь для следующего разработчика IMAP клиента будет полезна информация выше.

В IMAP ещё куча интересного. Команды для быстрой синхронизации это только начало, на деле можно ещё больше оптимизировать разные IMAP-команды, в зависимости от возможностей сервера, и сделать работу с почтой быстрее, экономичнее по сети и памяти и в целом приятнее.  Но об этом я расскажу позже.