К моему последнему проекту, написанному на 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