Разбираем email по косточкам

Как распарсить электронное письмо, чтобы получить последний ответ в переписке

a789ea3c8e481a63b029bd1e6dfa5419.jpg

Привет, меня зовут Елена Тихомирова, я работаю системным аналитиком в Платформе Сфера, разработке Холдинга Т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

Дополнительные примеры

Идентификатор заявки

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

\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:

теги в Gmail:

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

\n{1,}(^--\s*$)?\n+С уважением,|\n.*?-----Original Message-----|________________________________|

|\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() { 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://powerusers.microsoft.com/t5/Building-Flows/Extract-only-the-text-from-the-latest-reply-in-an-email-thread/td-p/122584

https://learn.microsoft.com/en-us/exchange/mail-flow-best-practices/regular-expressions-usage-transport-rules

https://regex101.com/

https://docs.meta-inf.hu/email-this-issue/v/email-this-issue-for-jira-server-data-center/faq/faq/how-to-remove-old-content-from-reply-emails (Jira)

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

© Habrahabr.ru