Разбираем email в Java

в 17:04, , рубрики: java, mail, Песочница, метки: ,

К моему последнему проекту, написанному на 80% на Java, надо было дописать модуль — парсер всех писем, проходящих через сервер. Религиозные мотивы модуля очень странные, но некоторыми деталями хотелось бы поделиться.

В наличии имеются:

Почтовый сервер Postfix со службой доставки Dovecot на CentOS. Ну и JVM.

Структура сообщений

Что такое электронное письмо, его составные части, их примерная структура, заголовки и MIME типы по-человечески описано на википедии.
Более интересной является структура имени файла письма на сервере. Пример имени новоиспеченного (не прочитанного/не запрошенного клиентом) письма:

1348142977.M852516P31269.mail.example.com,S=3309,W=3371


Имя состоит из флагов. Флаги разделяются запятыми, при создании нового письма указывается «куда», «когда» пришло письмо и его размеры.

  • Указываются два размера письма. Обычный Size, обозначенный «S» и Vsize, обозначенный символом «W», что есть rfc822.SIZE. (Тут отвечают на вопрос «Что такое RFC822.SIZE?» ).
  • Время указывается в формате Unix, в секундах.
  • В одном флаге со временем, через точку, могут идти «P» — ID процесса и «M» — счетчик в микросекундах, добавляемый для уникальности имени (могут быть и другие атрибуты, дополнительно в примечаниях)
  • Сервер указывается конечный, т.е. тот, на котором хранится письмо, а не relay-сервер в случае, если письмо было переслано.

Из этого полезным для меня было время создания письма (первые десять цифр). Однако, зачастую это время может отличаться от времени в заголовке письма, поэтому время в имени я использовал только для фильтрации сообщений в директории.

Дополнительные/клиентские флаги

Клиентский почтовый интерфейс (далее клиент) может добавлять в имя письма свои флаги. Начало клиентских флагов обозначается символом ":"

Как только клиент доберется запросит новые письма с сервера — отправляется запрос транспорту на перемещение каждого из запрошенных писем в директорию «прочитанные» и добавление к имени информационного флага (одного из двух), отделенного от последующих флагов запятой:

  • «1» — как говорит документация «Флаг, несущий экспериментальный смысл».
  • «2» — то, что у меня на практике было в 100% случаях. Означает то, что каждый последующий символ после запятой, является отдельным флагом.

Не смотря на то, что письмо на сервере уже лежит в папке «прочитанное», у пользователя оно будет отображаться как новое, т.к. клиенты считывают флаги, а не местонахождение письма.
То есть, только тогда, когда пользователь сам откроет письмо (либо другое действие с ним) и к его имени добавится флаг «S» (Seen), оно станет визуально «прочитанным». Различные действия над письмом, как и следовало бы ожидать, добавляют свои флаги, см. примечания.

Пример:
На сервер для нашего ящика пришло новое сообщение, его имя будет иметь приблизительно следующий вид:

1348142977.M852516P31269.mail.example.com,S=3309,W=3371

У нас на фоне запущен не дай Бог Outlook, который запрашивает список новых писем и говорит переместить их на сервере в директорию «прочитанные», добавляя при этом флаг:

1348142977.M852516P31269.mail.example.com,S=3309,W=3371:2,

Далее мы удаляем открываем Outlook и щелкаем на новое письмо, при этом добавляется флаг S:

1348142977.M852516P31269.mail.example.com,S=3309,W=3371:2,S

А потом еще отвечаем на него и удаляем:

1348142977.M852516P31269.mail.example.com,S=3309,W=3371:2,SRT

Как мы видим, флаги перечисляются без разделителей.

Примечания: некоторые клиенты имеют возможность настройки (не)перемещения письма в папку «прочитанное». Так же клиенты иногда добавляют не указанные в документации флаги «для своих нужд», на которые я особо не обращал внимания.
Больше полезной информации о флагах: cr.yp.to/proto/maildir.html

И немного Джавы

Для работы с письмами я использовал javax.mail. Нам любезно предоставлен абстрактный класс javax.mail.Message, хотя в данном случае я ограничился javax.mail.MimeMessage.
Модуль крутится на сервере, поэтому к сообщениям обращаемся локально (проверки и обработки исключений в коде опущены):

// в примере properties оставляю дефолтными
Session session = Session.getDefaultInstance(System.getProperties());   
FileInputStream fis = new FileInputStream(pathToMessage);
MimeMessage mimeMessage = new MimeMessage(session, fis);       

Теперь мы можем считать заголовки письма, которые ожидаются в ASCII. Если заголовок не найден, то нам вернется null. Например:

String messageSubject = mimeMessage.getSubject();
String messageId = mimeMessage.getMessageID();

Для определения списка получателей нам предоставлен метод getRecipients, принимающий в качестве аргумента Message.RecipientType. Метод возвращает массив объектов типа Address. Например, выведем список получателей письма:

for(Address recipient : mimeMessage.getRecipients(Message.RecipientType.TO)){
    System.out.println(recipient.toString());
}

Что-бы узнать отправителя(ей) письма, у нас есть метод getFrom. Так же возвращает массив объектов типа Address. Метод считывает заголовок «From», если тот отсутствует — читает заголовок «Sender», если отсутствует и «Sender» — тогда null.

for(Address sender : mimeMessage.getFrom()){
    System.out.println(sender.toString());
}

Далее разберем тело сообщения (в большинстве случаев нам нужен текст и вложения). Оно может быть составным (Mime multipart message), либо содержать только один блок формата text/plain. Если тело письма состоит только из вложения (без текста), оно все равно помечается как multipart message. По RFC822 формат указывается для тела письма (и его частей) в заголовке Content-Type.

 // Если контент письма состоит из нескольких частей
if(mimeMessage.isMimeType("multipart/mixed")){ 
         // getContent() возвращает содержимое тела письма, либо его части. 
         // Возвращаемый тип - Object, делаем каст в Multipart
  Multipart multipart = (Multipart) mimeMessage.getContent(); 
        // Перебираем все части составного тела письма
  for(int i = 0; i < multipart.getCount(); i ++){
         BodyPart part = multipart.getBodyPart(i); 
    //формат "text/plain" указывается даже для html содержимого (кроме передаваемой html страницы, но это уже вложение) 
        if(part.isMimeType("text/plain")){ 
           System.out.println(part.getContent().toString());
        }
         // Проверяем является ли part вложением
        else if(Part.ATTACHMENT.equalsIgnoreCase(part.getDisposition()){
        // Опускаю проверку на совпадение имен. Имя может быть закодировано, используем decode
                 String fileName = MimeUtility.decodeText(part.getFileName());
                 // Получаем InputStream
                 InputStream is = part.getInputStream(); 
                 // Далее можем записать файл, или что-угодно от нас требуется
                 ....
        }
  }
}
// Сообщение состоит только из одного блока с текстом сообщения
else if(mimeMessage.isMimeType("text/plain")){ 
       System.out.println(mimeMessage.getContent().toString());
}

Вот, собственно, и все. Надеюсь, что материал может быть полезным.
Так же на oracle.com есть полезный FAQ по javax.mail.

Автор: Encircled

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js