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

image

Давайте быстренько вспомним что мы узнали в прошлой статье:

  • IMAP — протокол с состояниями
  • Чтобы посмотреть содержимое инбокса, нужно для начала его выбрать командой SELECT
  • Для быстрой синхронизации ящика, в котором мы находимся, можно использовать команду NOOP
  • Чтобы не перебирать сообщения из локального хранилища для обновления ящика, из которого мы уже вышли,  можно использовать CONDSTORE и QRESYNC, при условии что данные расширения протокола поддерживает ваш сервер

Довольно!


Напомню команду для запроса тела письма:

1 FETCH number (BODY[])

Это создаст запрос для получения всего тела письма и всех аттачей. Просто посмотрим сколько занимает времени достать сообщение в 42 абзаца Lorem Ipsum и с картинкой в 2 мегабайт.

Сначала спросим размер письма на сервере. Делается это командой:

1 FETCH 18871 (RFC822.SIZE)

RFC822.SIZE возвращает размер сообщения в байтах:

* 18871 FETCH (RFC822.SIZE 3937793)

То есть в итоге наше сообщение занимает почти 4 мегабайта.

Теперь все-таки воспользуемся запросом полного тела письма и глянем на время:

1 OK Fetch completed (0.007 + 3.265 secs).

3.3 секунды! И это только одно сообщение с аттачем, а представьте их таких будет весь ящик. Тогда на загрузку хотя бы двадцати первых уйдет больше минуты.

Согласитесь, плохи дела клиента, который в 2020 году не может синхронизировать почту быстрее чем за минуту. Но что же делать?

Дай разок откусить


Если прошелестить RFC3501 в пункте 6.5.4, который описывает возможные параметры для команды FETCH, можно заметить интересный запрос:

BODY[<section>]<<partial>>

  • section — какую из частей письма получить
  • partial — размер этой части

Как строится partial? А очень легко. Через точку пишется сначала байт с которого нужно начать читать, а потом сколько байт в целом нужно прочитать:

BODY[<section>]<<0.1024>>

Здесь мы запрашиваем часть письма с нулевого байта по 1024.

Окей, что есть section? Для начала я расскажу о таком полезном параметре в запросе FETCH как BODYSTRUCTURE:

1 FETCH 18871 (BODYSTRUCTURE)

Этот параметр, как вы наверно поняли из сигнатуры, возвращает структура письма в виде, описанном в MIME-IMB.

* 18871 FETCH (BODYSTRUCTURE ((("text" "plain" ("charset" "utf-8") NIL NIL "quoted-printable" 25604 337 NIL NIL NIL NIL)("text" "html" ("charset" "utf-8") NIL NIL "quoted-printable" 29593 390 NIL NIL NIL NIL) "alternative" ("boundary" "--=_Part_763_774309787.1586268692") NIL NIL NIL)("image" "jpeg" ("name" "IMG_20200217_000236.jpg") NIL NIL "base64" 3880726 NIL ("attachment" ("filename" "IMG_20200217_000236.jpg")) NIL NIL) "mixed" ("boundary" "--=_Part_210_297656922.1586268692") NIL NIL NIL))


Один лишь взгляд на эту структуру, и голова кругом? Не бойтесь, сейчас разберемся. Сопоставим открывающиеся и закрывающиеся скобки.

(
BODYSTRUCTURE 
(
[1] (
[1.1] ("text" "plain" ("charset" "utf-8") NIL NIL "quoted-printable" 25604 337 NIL NIL NIL NIL)
[1.2] ("text" "html" ("charset" "utf-8") NIL NIL "quoted-printable" 29593 390 NIL NIL NIL NIL) "alternative" ("boundary" "--=_Part_763_774309787.1586268692") NIL NIL NIL
)
[2] ("image" "jpeg" ("name" "IMG_20200217_000236.jpg") NIL NIL "base64" 3880726 NIL ("attachment" ("filename" "IMG_20200217_000236.jpg")) NIL NIL) "mixed" ("boundary" "--=_Part_210_297656922.1586268692") NIL NIL NIL
)
)


Вы можете заметить, что около некоторых скобок я проставил цифры. Это и есть section.
Как их просчитать? Первую скобку нужно пропустить, так как она просто содержит в себе ответ на запрос, далее каждую открывающуюся скобку нужно нумеровать по правилу как нумеруются заголовки в документах:

  • Каждую открывающую скобку нумеруем с учетом предыдущей секции
  • Если секция вложенная, то к номеру предыдущей добавляется текущая через точку
  • Если секция не вложена, её номер увеличиваем на единичку


Например в данном случае в первой части которая заканчивается на «alternative» (то есть это часть письма multipart/alternative, где мы вольны выбирать какую из частей отображать для пользователя) есть две секции, которые нумеруется через точку. Я встречал сервера где могут быть и трехуровневые вложенности (то есть [1.1.1], [1.1.2], etc).
Разберем часть [1.1] просматривая структуру всего это добра в документе MIME-IMB. Судя по нему сначала идет Content-Type хэдэр. В него включен :

  • MIME-тип, здесь он text/plain
  • Кодировка (charset=utf8)


Далее два параметра, которые записаны как NIL. Скажу честно, я не разбирался что это, но пока мне это не понадобилось, поэтому я пропущу. Прошу прощение за такое легкомыслие
Далее идёт Content-Transfer-Encoding хэдэр, в него включены, который описывает механизм энкодинга, здесь это quoted-printable. 
Следующие два числа описывают размер части в байтах и количество строк, если возможно их получить. С их помощью мы можем посчитать сколько взять байт, чтобы отобразить определенное количество строчек.
Следующие строчки, которых нет в этой части:

  • Content-Id, который используется в инлайнах письма
  • Content-Description, строчка которая описывает что это за часть


Для остальных двух параметров найти однозначного ответа что это такое мне не удалось, однако один из этих параметров может содержать MD5 части, что иногда может быть полезно.
Для части [2] все то же самое, за исключением того, что это изображение, аттачмент с именем, и энкодингом base64. Если все ещё до конца не понятно что тут происходит, то вот на этом сайте отлично разложено как именно нужно просчитывать section.

Что это дает? А то что на этапе отображения письма мы уже можем запросить лишь верхнюю часть контента и не грузить аттачменты, пока пользователь сам не зайдет в сообщение и не нажмет кнопку «загурзить». Вся информация для отображения аттачей у нас приходит в BODYSCTRUCTURE так что название, формат и размер можно показать и без загрузки самого аттача. 

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

1 fetch 18871 (body[1.1]<0.1024>)
* 18871 FETCH (BODY[1.1]<0> {1024}
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Vivamus consecte=
tur enim in nisi venenatis, id varius tellus viverra. Praesent et enim te=
llus. Nunc vestibulum diam tortor, id posuere turpis tempor luctus. Vivam=
us molestie non nunc nec placerat. Cras finibus ut erat et tristique. Cur=
abitur vitae commodo risus. Etiam sed scelerisque erat. Quisque cursus bl=
andit finibus. Nullam ac lectus accumsan, molestie quam non, mollis urna.=
 Nulla at arcu in libero condimentum mollis ut non velit. Vestibulum sed =
risus et magna congue iaculis. Vestibulum nec interdum elit, ut commodo m=
auris. Nulla ipsum leo, vestibulum nec ligula non, elementum ullamcorper =
risus. Nunc et malesuada sem, id venenatis massa. Integer dolor ante, max=
imus in eleifend nec, ultricies ut risus. Mauris posuere eget tortor at p=
orttitor.=0AIn porta elementum ornare. Suspendisse aliquam, tortor sed al=
iquam bibendum, nulla ante rhoncus elit, placerat accumsan augue nibh non=
 est. Duis finibus vel tortor finibu)
1 OK Fetch completed (0.073 + 0.000 + 0.072 secs).


Каких-то 100 миллисекунд и мы уже видим часть контента письма! Это просто отличный результат, учитывая что ранее нам потребовалось почти 4 секунды для загрузки контента одного письма. Дальше можно просто грузить весь контент письма в фоновом потоке, снаружи будет казаться что письма грузятся моментально. Всего-то потребовалось посмотреть структуру письма и загрузить лишь то, что требуется для быстрого отображения. 
Один только момент. Данный запрос сделает так, что письмо на сервере будет отображаться как прочитанное. Но можно это поправить, добавив лишь PEEK в запрос тела

1 fetch 18871 (BODY.PEEK[1.1]<0.1024>)
* 18871 FETCH (BODY[1.1]<0> {1024}
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Vivamus consecte=
tur enim in nisi venenatis, id varius tellus viverra. Praesent et enim te=
llus. Nunc vestibulum diam tortor, id posuere turpis tempor luctus. Vivam=
us molestie non nunc nec placerat. Cras finibus ut erat et tristique. Cur=
abitur vitae commodo risus. Etiam sed scelerisque erat. Quisque cursus bl=
andit finibus. Nullam ac lectus accumsan, molestie quam non, mollis urna.=
 Nulla at arcu in libero condimentum mollis ut non velit. Vestibulum sed =
risus et magna congue iaculis. Vestibulum nec interdum elit, ut commodo m=
auris. Nulla ipsum leo, vestibulum nec ligula non, elementum ullamcorper =
risus. Nunc et malesuada sem, id venenatis massa. Integer dolor ante, max=
imus in eleifend nec, ultricies ut risus. Mauris posuere eget tortor at p=
orttitor.=0AIn porta elementum ornare. Suspendisse aliquam, tortor sed al=
iquam bibendum, nulla ante rhoncus elit, placerat accumsan augue nibh non=
 est. Duis finibus vel tortor finibu)
1 OK Fetch completed (0.001 + 0.000 secs).


И voila! Письмо остается как непрочитанное и часть контента мы получили.
Все становится ещё проще если на вашем сервере реализована возможность запроса  PREVIEW

1 fetch 18871 (PREVIEW)
* 18871 FETCH (PREVIEW (FUZZY "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Vivamus consectetur enim in nisi venenatis, id varius tellus viverra. Praesent et enim tellus. Nunc vestibulum diam tortor, id posuere turpis t"))
1 OK Fetch completed (0.001 + 0.000 secs).


Тут мы вообще не тратим время на запрос структуры и получаем превью сообщения мгновенно. Но не стоит забывать, что запрос структуры полезен для определения аттачментов, чтобы их не загружать в холостую.

Выждем


Почти любой клиент почты реализует кнопку «обновить», если пользователь хочет прямо сейчас получить новые письма. Но как то это не круто для нашего времени, где есть нотификации как в девайсах так и в браузерах. Что на этот счет говорит IMAP? А он говорит IDLE. Эта операция удерживает соединение с папкой и оповещает об изменениях папки. Обратите внимание, не ящика, а папки. Для этого нужно чтобы сервер реализовывал возможность IDLE. 

Сначала выберем папку,  для которой сервер будет слать оповещения, а затем включим IDLE

1 SELECT Inbox
* FLAGS (\Answered \Flagged \Deleted \Seen \Draft $Forwarded $MDNSent)
* OK [PERMANENTFLAGS (\Answered \Flagged \Deleted \Seen \Draft $Forwarded $MDNSent \*)] Flags permitted.
* 18872 EXISTS
* 0 RECENT
* OK [UNSEEN 18685] First unseen.
* OK [UIDVALIDITY 1532079879] UIDs valid
* OK [UIDNEXT 20155] Predicted next UID
* OK [HIGHESTMODSEQ 26338] Highest
1 OK [READ-WRITE] Select completed (0.002 + 0.000 + 0.001 secs).
1 IDLE
+ idling


Ответ "+idling" оповещает о включении айдла на папке. Что будет если придет новое письмо?

* 18873 EXISTS
* 1 RECENT


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

1 fetch 18873 (BODY.PEEK[1.1]<0.1024>)
* 18873 FETCH (BODY[1.1]<0> {1024}
---- Original Message ---- Tue, Apr 7, 2020, 17:11=0ASubject=
: Lorem Ipsum=0A  Lorem ipsum dolor sit amet, consectetur adipiscing elit=
. Vivamus consectetur enim in nisi venenatis, id varius tellus viverra. P=
raesent et enim tellus. Nunc vestibulum diam tortor, id posuere turpis te=
mpor luctus. Vivamus molestie non nunc nec placerat. Cras finibus ut erat=
…


Очень важно понимать. IDLE требует отдельное соединение, поэтому нельзя в той же самой сессии получать изменения и запрашивать сообщения
Что ещё умеет IDLE? Он умеет оповещать об удаленных письмах и письмах, у которых изменились флаги. Давайте я ради примера просмотрю письмо, тем самым накинув на него флаг "/seen" и удалю письмо.

* 18873 FETCH (FLAGS (\Seen \Recent))
* OK Still here
* OK Still here
* 18873 EXPUNGE
* 18871 EXPUNGE
* 0 RECENT


Я удалил цепочку писем (18873, 18871) и просмотрел другое письмо (FETCH ответ). Почему это письмо стало 18871ым? Потому что IMAP пересчитывает номер письма если что-то изменилось. Так как оно стало верхним, то его номер также изменился. 
С помощью IDLE мы можем быстро синхронизировать состояние ящика, но неприятно, что он требует отдельное соединение. Может ли быть лучше? Именно поэтому я здесь.

Крикни как сделаешь


Что если я вам скажу, что есть фича, которая позволяет в одном и том же соединение получать оповещения от сервера, да и ещё специально настраиваемые под ящики, да ещё и не одно. Звучит как сказка, но не сходите с ума, это реальный capability NOTIFY. Он умеет очень многое, например:

  • Настраиваться на конкретные папки, от которых мы ждём оповещения
  • Слушать изменения статуса папки (прочитанные письма, новые письма)
  • Настраивать формат оповещения, то есть то, что мы хотим видеть при изменении папки
  • Слушать изменения имени папок
  • Слушать изменения метаинформации папок


Давайте посмотрим на примере как мы можем слушать изменения статуса папки

1 notify set (inboxes (MessageNew FlagChange MessageExpunge))
1 OK NOTIFY completed (0.001 + 0.000 secs).


Теперь сервер нам будет слать оповещения со статусами папок, к пример я добавлю пару сообщений в разные папки

* STATUS INBOX/Ozon (MESSAGES 312 UIDNEXT 321 UNSEEN 48)
* STATUS "INBOX/Company News" (MESSAGES 178 UIDNEXT 179 UNSEEN 1)
* STATUS "INBOX/Company News" (MESSAGES 177 UIDNEXT 179 UNSEEN 0)


Разберу команду:
Сначала идёт команда NOTIFY SET. Далее в скобках выбирается какие папки будем слушать:

  • Inboxes — для всех папок которые можно выбрать
  • Personal — папки которые находятся в неймспейсе юзера
  • Subscribed — папки, на которые подписан юзер
  • Subtree — поддерево папки, которую нужно указать
  • Mailboxes — здесь можно перечислить папки которые следует слушать
  • Selected — оповещение только для выбранных папок


И параметры которые отвечают за фильтр оповещений:

  • MessageNew — если пришло новое сообщение
  • FlagChange — если изменился флаг
  • MessageExpunge — если сообщение было удалено или перенесено


Но с такой командой мы не можем получать параметры нового, измененного или удаленного письма. Для этого нужно выбрать параметр Selected и указать что именно возвращать. Мы можем добавить ещё одно оповещение, не удаляя предыдущее

1 notify set status (selected (MessageNew (uid preview) MessageExpunge))


Здесь внутри MessageNew мы указываем параметры, которые должна вернуть нотификация. Выберу Inbox и снова кину себе lorem ipsum.

* 18868 FETCH (UID 20157 PREVIEW (FUZZY "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Vivamus consectetur enim in nisi venenatis, id varius tellus viverra. Praesent et enim tellus. Nunc vestibulum diam tortor, id posuere turpis t"))


Как вам? Для айдла нам нужно держать два соединения, одно из которых ещё и запрашивает сообщения, которые вернул нам айдл. Тут же нам приносят все на блюдечке. 
А так мы можем слушать изменения имени папок

1 notify set (inboxes (MailboxName))


Переименуем какую нибудь папку и посмотрим результат

* LIST () "/" 1111 ("OLDNAME" (aaaa))


И теперь мы знаем, что была папка «аааа», а стала «1111»
Теперь можно слушать изменение флагов и удаление месседжей. Для этого нужен параметр FlagChange

1 notify set (selected (MessageNew (uid) FlagChange MessageExpunge))


И при изменении флагов сообщений и удалении мы получим

* 18865 EXPUNGE
* 18864 FETCH (FLAGS ())
* 18864 FETCH (FLAGS (\Answered))


Что дальше


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