Как распарсить электронное письмо, чтобы получить последний ответ в переписке
Привет, меня зовут Елена Тихомирова, я работаю системным аналитиком в Платформе Сфера, разработке Холдинга Т1. Поделюсь своим опытом, полученным в одном из проектов, где я реализовала автоматизированную обработку входящей почты: пользователь отвечает на email-уведомление от help desk или трекера задач, обработчик извлекает из письма необходимые данные, обогащает их и передаёт соответствующему сервису для дальнейшей проверки и публикации.
Современные трекеры задач (далее порталы) позволяют пользователям создавать новые задачи и писать комментарии к существующим, редактировать их содержание, отправляя электронное сообщение на специально предназначенный для этого почтовый ящик. При публикации на портале комментария к существующей заявке нужно определить последний ответ в цепочке. Для этого необходимо выделить текст сообщения и прикреплённые пользователем файлы. Казалось бы, задача простецкая. Но если углубиться в подробности, то возникает много нюансов. Давайте разберёмся.
Для парсинга на многих порталах используются маркеры-разделители сообщений: с помощью регулярных выражений можно отфильтровать весь текст ниже маркера, расположенного в письме, сам маркер и, опционально, часть текста выше. Также можно проверить, что маркер находится в правильном месте письма. Работает это следующим образом. Портал рассылает уведомления пользователям о состоянии определённой заявки. Далее получает ответ пользователя, парсит и публикует его. При этом маркером может быть как часть текста письма‑рассылки, так и другие части этого письма или ответа на него.
Лишней информацией («хвостом») являются следующие части исходного письма пользователя:
предыдущее сообщение (это автоматическая рассылка о статусе заявки);
заголовки предыдущего сообщения (имеют разные форматы, форматируются почтовым клиентом пользователя, отправившего последнее письмо);
подпись отправителя;
пустые строки до и после подписи.
Вся эта схема работает при соблюдении следующих условий:
Пользователь в своём ответе не стёр предыдущее сообщение (от портала) и написал свой текст выше. В противном случае публикуется письмо целиком.
Пользователь не стёр тему письма с идентификатором заявки. В противном случае портал пытается извлечь идентификатор из письма‑рассылки.
В случае неудачи письмо публикуется как новая заявка.
Способы отсечения ненужной информации
1. Взять строки выше подписи пользователя и пустых строк выше неё.
Преимущества:
Работает с любыми сообщениями, а не только ответами на сообщения портала.
Максимально очищенный текст, никакого «балласта».
Максимально простое решение, если пользователи работают в одной организации: они используют один и тот же почтовый клиент и структуру подписи. Если нужно обрабатывать письма от сторонних пользователей, то регулярные выражения можно усложнить или упростить (однако снизится точность).
Недостатки:
Подписи под текстом может и не быть, так что этот фильтр нужно обязательно сочетать с другими.
Сложно (но возможно) составить выражение, которое покрывало бы все варианты подписи (в случае обработки писем от сторонних пользователей).
2. Взять строки выше разделителей почтовых клиентов, используемых в организации.
Преимущества:
Если такой разделитель есть, то является однозначным указанием.
Находится в любых сообщениях пользователя, а не только в тех, которые являются ответами на сообщения портала.
В тексте остаётся минимум «балласта».
Недостатки:
Разделителя в письме может и не быть.
В зависимости от почтового клиента и его настроек у этого разделителя может быть много вариантов, причём они совершенно разные в plaintext и HTML, что несколько усложняет задачу парсинга.
3. Взять строки выше заголовков предыдущего сообщения.
Преимущества:
Такой разделитель находится в любых сообщениях пользователя, не только тех, которые являются ответами на сообщения портала.
В тексте остаётся минимум «балласта».
В рамках одной организации используется один и тот же почтовый клиент (но настройки у пользователей могут быть разные, в том числе языковые и настройки формата сообщения (plaintext, HTML)). Тем не менее, если не нужно обрабатывать письма от сторонних пользователей, то требуется регулярное выражение только для одного типа заголовков с небольшими вариациями.
Недостатки:
Сложно (но возможно) составить выражение, которое покрывало бы все варианты заголовков различных почтовых клиентов (в случае обработки писем от сторонних пользователей).
Не отсекается подпись отправителя с пустыми строками вокруг неё (остаётся небольшой малоинформативный «хвост»).
4. Взять строки выше имени ящика, с которого рассылаются автоматические сообщения портала. Имя ящика располагается в заголовках предыдущего письма с текстом типа «От:». Почтовые клиенты иногда пишут и имя, и email-адрес отправителя цитируемого письма, но иногда — только имя, поэтому лучше ориентироваться именно на него.
Преимущества:
Работает на 99,9% с ответами на сообщения портала (если пользователь не впишет имя этого ящика в свой текст, что маловероятно).
Недостатки:
Не работает в случае писем, которые не являются ответом на сообщения портала (но это нормально, тогда вся переписка должна быть релевантна и ничего отсекать не нужно).
Не отсекается подпись отправителя с пустыми строками вокруг неё (остаётся небольшой малоинформативный «хвост»).
5. Взять текст выше произвольной строчки в письме, которая выступает в роли разделителя сообщений.
Преимущества:
Всегда работает с ответами на сообщения портала (если пользователь не удалит эту строчку).
Недостатки:
Не работает в случае писем, которые не являются ответом на сообщения портала (но это нормально, тогда вся переписка должна быть релевантна и ничего отсекать не нужно).
Не отсекаются заголовки предыдущего сообщения и подпись отправителя с пустыми строками вокруг неё (остаётся малоинформативный «хвост»).
Я предпочитаю использовать сочетание нескольких фильтров, описанных в пунктах 1, 4 и 5.
Далее рассмотрим самые простые примеры регулярных выражений для определения места в письме, выше которого располагается релевантная для публикации информация.
Примеры регулярных выражений
Для обработки и plaintext, и HTML
В исходном формате письма (MIME) текст может передаваться или в plaintext, или в HTML, или в обоих форматах сразу, поэтому необходимо анализировать оба варианта, а публиковать текст из одного, оставив второй формат как резервный.
Примечание: .*
убирает теги слева от маркера до начала новой строки. С plaintext можно не использовать и применять ^ $
для большей точности фильтрации.
-
\n{1,}.*?С уважением,
Фильтр по этому маркеру уберёт или всё до подписи, подпись и пустые строки выше неё, или всё до подписи и подпись, но оставит знак
--
(если он есть над подписью) и пустые строки выше подписи. \n{1,}.*‑--‑Original Message‑---
-
\n{1,}.*?On.*?\n{0,2}.*? wrote:
Gmail, OS X mail. Русского аналогичного текста нет: Gmail сразу пишет дату и заканчивает строку адресом отправителя с двоеточием.
-
\n{1,}.*?От: (.|\n)*?Отправлено:\s
\n{1,}.*?From: (.|\n)*?Sent:\s
Outlook и некоторые другие клиенты.
-
\n*.*?<имя почтового ящика>
Нужно указать имя почтового ящика портала для рассылки писем пользователям продуктов-потребителей, потому что адрес в заголовках предыдущего письма может не отображаться.
-
\n{1,}.*?<произвольный разделитель>
В качестве маркера выступает произвольная строка текста в письме-рассылке (логично, если первая). Публикуется весь текст выше этой строки.
Для обработки plaintext
\n{1,}^‑\s*$
-
________________________________
32 символа подчёркивания, Outlook.
Для обработки HTML
-
<div id=”Signature”>
Outlook
-
<div id=”divRplyFwdMsg”>
Outlook
-
<div style=”border:none; border‑top:solid #E1E1E1 1.0pt; padding:3.0pt 0in 0in 0in”>
Outlook
Дополнительные примеры
Идентификатор заявки
Обычно он содержится в теме письма-рассылки. Для его извлечения можно использовать модификацию следующего выражения, или в рассылке заключать идентификатор в квадратные скобки и при парсинге брать текст из них.
\b[A-Z_]{2,10}-\d{1,10}\b
Последний ответ пользователя в цепочке сообщений
Для извлечения ответов на письма-рассылки портала.
\n{1,}.*?Вы можете оставить комментарий
Уникальный текст первой строчки из письма-рассылки пользователям (полная строка может быть длиннее, например: «Вы можете оставить комментарий к этому обращению ответным письмом»).
Примечание: если вы используете в тексте маркера кавычки и некоторые другие специальные символы, то в HTML-версии они заменятся на теги (и это нужно учитывать в соответствующем регулярном выражении).
Результат: текст пользователя, его подпись (при наличии), пустые строки после неё, заголовки предыдущего сообщения (От:, Кому: и т. д.)
\n*.*?<имя почтового ящика>
Рекомендуемый способ: нужно указать имя почтового ящика портала для рассылки писем пользователям, потому что адрес в заголовках предыдущего письма может не отображаться.
Результат: текст пользователя, его подпись (при наличии). А заголовков и части пустых строк перед ними уже не будет.
Это выражение можно усложнить, чтобы убедиться, что имя ящика действительно пришло в заголовках предыдущего сообщения. Например:
\n{1,}.*On .*\n?.*<имя ящика>.*\n?.* wrote:
\n{1,}.*От: .*<имя ящика>.*(.|\n)*Отправлено:
Для извлечения последнего ответа из любой переписки.
\n{1,}.*?С уважением,
Результат: фильтр по этому маркеру уберёт или всё ниже подписи, подпись и пустые строки выше неё, или всё ниже подписи и подпись, но оставит --
(если это есть над подписью).
Примечание: способ действует, если есть подпись и если в ней есть этот текст. Надёжней писать регулярное выражение для всего блока переписки, чем опираться только на одну фразу, и использовать дополнительный фильтр на случай, если подписи под текстом пользователя нет (например, из предыдущего пункта).
Действует только на plaintext-часть письма:
\n{1,}(^--\s*$\n)?С уважением,
Результат: фильтр по этому маркеру уберёт всё ниже подписи, подпись и пустые строки выше неё.
Действует только на HTML-часть письма: можно использовать выражение из предыдущего пункта или брать в качестве маркера следующие теги или их кусок:
теги в Outlook:
<div id="Signature">
<div id="divRplyFwdMsg">
теги в Gmail:
<div dir="ltr" class="gmail_signature">
<div class="gmail_quote">
Также можно комбинировать различные маркеры в одном регулярном выражении. «На все случаи жизни». Например, так:
\n{1,}(^--\s*$)?\n+С уважением,|\n.*?-----Original Message-----|________________________________|<div id="divRplyFwdMsg">|\n{1,}.*?On\s.*?\n?.*?\swrote:|\n{1,}.*?От:\s(.|\n)*?Отправлено:\s|\n{1,}.*?From:\s(.|\n)*?Sent:\s
Однако применение усложнённых регулярных выражений замедляет обработку текста, так что, возможно, лучше написать каскадные фильтры, как показано в следующем разделе.
Пример кода с форумов, в котором есть каскадные фильтры:
new Regex("From:\\s*" + Regex.Escape(_mail), RegexOptions.IgnoreCase);
new Regex(Regex.Escape(_mail) + "\\s+wrote:", RegexOptions.IgnoreCase);
new Regex("\\n.*On.*(\\r\\n)?wrote:\\r\\n", RegexOptions.IgnoreCase | RegexOptions.Multiline);
new Regex("-+original\\s+message-+\\s*$", RegexOptions.IgnoreCase);
new Regex("from:\\s*$", RegexOptions.IgnoreCase);
def extract_reply(text, address)
regex_arr = [
Regexp.new("From:\s*" + Regexp.escape(address), Regexp::IGNORECASE),
Regexp.new("<" + Regexp.escape(address) + ">", Regexp::IGNORECASE),
Regexp.new(Regexp.escape(address) + "\s+wrote:", Regexp::IGNORECASE),
Regexp.new("^.*On.*(\n)?wrote:$", Regexp::IGNORECASE),
Regexp.new("-+original\s+message-+\s*$", Regexp::IGNORECASE),
Regexp.new("from:\s*$", Regexp::IGNORECASE)
]
text_length = text.length
#calculates the matching regex closest to top of page
index = regex_arr.inject(text_length) do |min, regex|
[(text.index(regex) || text_length), min].min
end
text[0, index].strip
end
public string ExtractReply(string text, string address)
{
var regexes = new List<Regex>() { new Regex("From:\\s*" + Regex.Escape(address), RegexOptions.IgnoreCase),
new Regex("<" + Regex.Escape(address) + ">", RegexOptions.IgnoreCase),
new Regex(Regex.Escape(address) + "\\s+wrote:", RegexOptions.IgnoreCase),
new Regex("\\n.*On.*(\\r\\n)?wrote:\\r\\n", RegexOptions.IgnoreCase | RegexOptions.Multiline),
new Regex("-+original\\s+message-+\\s*$", RegexOptions.IgnoreCase),
new Regex("from:\\s*$", RegexOptions.IgnoreCase),
new Regex("^>.*$", RegexOptions.IgnoreCase | RegexOptions.Multiline)
};
var index = text.Length;
foreach(var regex in regexes){
var match = regex.Match(text);
if(match.Success && match.Index < index)
index = match.Index;
}
return text.Substring(0, index).Trim();
}
Итоги
Очистить текст письма от лишней информации возможно. Как и подобрать фильтры для заголовков и подписей сообщений в рамках одной организации и с ограниченным количеством внешних организаций. Необходимо использовать несколько фильтров по принципу нескольких линий обороны: если не сработает первая (подписи), то применяются последующие (разделители используемых в организации почтовых клиентов, варианты заголовков, имя почтового ящика для автоматической рассылки портала). И самая последняя линия (маркер-разделитель или знаки цитирования) действует безотказно, но оставляет много лишнего.
Полезные ссылки
https://www.reddit.com/r/csharp/comments/1ayn6b6/parsing_HTML_email_body_to_retrieve_the_latest/
https://github.com/fiedl/extended_email_reply_parser
https://stackoverflow.com/questions/278788/parse-email-content-from-quoted-reply
https://stackoverflow.com/questions/1672144/parsing-email-conversations-with-regular-expressions
https://stackoverflow.com/questions/2168610/how-can-i-efficiently-parse-HTML-with-java