Электронный почтальон

 

 

Никита Ладошкин @nikiladonya

Е-почта
Касается
Всех

Несмотря на то, что всем приходилось ее использовать, не все понимают, как она работает

Схема донельзя простая:

  • MUA - это  Outlook, Thunderbird и другие.  
  • MTA - это exim4, postfix, Microsoft Exchange Server, Lotus Domino и т. д.

SMTP протокол (RFC 821, RFC 5321)

----------------------------------------

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

Пример SMTP сессии:

            S: MAIL FROM:<Smith@Alpha.ARPA>
            R: 250 OK

            S: RCPT TO:<Jones@Beta.ARPA>
            R: 250 OK

            S: RCPT TO:<Green@Beta.ARPA>
            R: 550 No such user here

            S: RCPT TO:<Brown@Beta.ARPA>
            R: 250 OK

            S: DATA
            R: 354 Start mail input; end with <CRLF>.<CRLF>
            S: Blah blah blah...
            S: ...etc. etc. etc.
            S: <CRLF>.<CRLF>
            R: 250 OK

Сами письма

----------------------------------------

Заголовки письма описываются стандартами RFC:

  • RFC 2076 — Common Internet Message Headers (общепринятые стандарты заголовков сообщений), включает в себя информацию из других RFC: RFC 822, RFC 1036, RFC 1123, RFC 1327, RFC 1496, RFC 1521, RFC 1766, RFC 1806, RFC 1864, RFC 1911).
  • RFC 4021 — Registration of Mail and MIME Header Fields (регистрация почты и поля заголовков MIME).

----------------------------------------

Тело же представляет собой текст с возможным кодированием в  Base64 или Quoted-Printable.

Пример письма:

MIME-Version: 1.0
Received: by 10.103.42.196 with HTTP; Wed, 23 Aug 2017 13:26:32 -0700 (PDT)
Date: Wed, 23 Aug 2017 23:26:32 +0300
Delivered-To: elenagaricheva95@gmail.com
Message-ID: <CAJ_KOjzoELpHKw+4B107+Q=XdTX3dvBy3cJ_zxVnOBK27FxqDw@mail.gmail.com>
Subject: Cool title
From: "Елена Гаричева" <elenagaricheva95@gmail.com>
To: "Никита Ладошкин" <nikiladonya@gmail.com>
Content-Type: multipart/alternative; boundary="94eb2c052bd414a38905577187df"

--94eb2c052bd414a38905577187df
Content-Type: text/plain; charset="UTF-8"

Text in body

--94eb2c052bd414a38905577187df
Content-Type: text/html; charset="UTF-8"

<div dir="ltr">Text in body</div>

--94eb2c052bd414a38905577187df--

Пользоваться со стороны клиента просто:

import os
import smtplib
from email.mime.text import MIMEText
from email.mime.image import MIMEImage
from email.mime.multipart import MIMEMultipart

msg = MIMEMultipart()
msg['Subject'] = 'The cool title'
msg['From'] = 'foo@gmail.com'
msg['To'] = 'bar@gmail.com'

msg.attach(MIMEText('Text of body))

# берем любое изображение
with open(file_path, 'rb') as fp:
    img = MIMEImage(fp.read())
    img['Content-Disposition'] = 'attachment; filename="%s"' % os.path.basename(file_path)
    msg.attach(img)



# посылаем сообщение
s = smtplib.SMTP('localhost')
s.sendmail(me, [you], msg.as_string())
s.quit()

Также доступен асинхронный вариант:

import asyncio
from email.mime.text import MIMEText

import aiosmtplib

loop = asyncio.get_event_loop()

async def send():
    smtp = aiosmtplib.SMTP(hostname='localhost', port=10025, loop=loop)
    await smtp.connect()
    message = MIMEText('Sent via aiosmtplib')
    message['From'] = 'root@localhost'
    message['To'] = 'somebody@example.com'
    message['Subject'] = 'Hello World!'
    await smtp.send_message(message)

loop.run_until_complete(send())

Однако, в суровом мире кровавого энтерпрайза не всегда можно быть клиентом. 

 

И даже сервером.

Например, может возникнуть задача встроиться в поток (то есть Вам придется стать milter'ом):

  • Ради безопасности
  • Ради чистоты
  • Умной фильтрации
  • Сортировки
  • Проверку подлинности и цифровой подписи
  • Любого кастомного поведения

Естественно, есть вариант встать релеем:

Но это приносит кучу проблем:

  • Админам придется перенастраивать все подряд
  • Аутентификация - штука и так не веселая, а при добавлении еще одного почтовика становится совсем грустно
  • В принципе, ЕЩЕ ОДИН ПОЧТОВИК - количество имевшихся проблем x2

Рассмотрим возможные варианты на примере реальных MTA:

Например, postfix, самый популярный из свободных почтовиков:

Postfix имеет две возможности:

Первая (подходит также для sendmail) - это фильтрация до очереди с использованием С библиотека libmilter от sendmail. Вещь достаточно неплохая.

class myMilter(Milter.Base):

  def __init__(self): # каждое соединение создается новый экземпляр
    self.id = Milter.uniqueID()

  def hello(self, heloname):
    # (self, 'mail.example.com')
    self.H = heloname
    self.log("HELO %s" % heloname)
    if heloname.find('.') < 0:  # невалидная конструкция
      self.setreply('550','5.7.1','Нормально делай, нормально будет!')
      return Milter.REJECT # отклоняем
 
    return Milter.CONTINUE # если все норм, принимаем

  @Milter.noreply
  def header(self, name, hval):
    self.fp.write("%s: %s\n" % (name,hval)) # добавляем пришедшим заголовок
    return Milter.CONTINUE

Мы тут про Python и нам везет - есть обертки, например, PyMilter (http://pythonhosted.org/pymilter/), суть которой заключается переопределении методов-колбэков на различные SMTP-события (CONNECT) и SMTP-комманды (HELO, MAIL FROM и т. д.)

Особенности 

+ Подключается просто по сокету

+ Есть готовое API

- Фильтрация перед очередью требует приличные ресурсы и не терпит таймаутов

-  Postfix имеет два набора почтовых фильтров: фильтры, которые используются только для SMTP и фильтры для не-SMTP почты, по историческим причинам они отличаются в поведении, второе эмулирует первое

Второй способ - фильтрация после очереди.

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

 

 

 

 

Этот подход отделяет процесс получения писем от процесса фильтрации, и дает максимальный контроль над тем, как много процессов фильтрации могут использоваться параллельно.

Lite-версия - написать простейший скрипт на баше или Python, который по pipe забирает почту и шлет обратно якобы уже обработанную

# пример настройки такого фильтра в конфиге postfix'а

# =============================================================
  # service type  private unpriv  chroot  wakeup  maxproc command
  #               (yes)   (yes)   (yes)   (never) (100)
  # =============================================================
  filter    unix  -       n       n       -       10      pipe
    flags=Rq user=filter null_sender=
    argv=/path/to/script -f ${sender} -- ${recipient}

Но при использовании фильтрации через скрипт, представленного выше, произойдет четырехкратная потеря производительности Postfix для транзита почты.

И придется отвечать return кодами. И все рано или поздно повалится.

Вторая версия второго способа - самая лучшая. Мы просто пишем SMTP сервер, который ничем не занимается, кто своей основной задачи. Практически unix way.

+ Падение производительности всего в два раза

+ Фильтруется почта, приходящая отовсюду

+ Можно выстаивать цепочку фильтров

Ну а SMTP сервер на Python - дело нехитрое.

Например, используя smtpd:

import smtplib
from stmpd import SMTPServer

class AddHeaderProxy(SMTPServer):
    def process_message(self, peer, mailfrom, rcpttos, data):
        lines = data.split('\n')

        i = 0
        for line in lines:
            if not line:
                break
            i += 1

        lines.insert(i, 'X-Peer: %s' % peer[0])
        data = NEWLINE.join(lines)
        self._deliver(mailfrom, rcpttos, data)

    def _deliver(self, mailfrom, rcpttos, data):
        s = smtplib.SMTP()
        s.connect(self._remoteaddr[0], 10026)
        try:
            refused = s.sendmail(mailfrom, rcpttos, data)
        finally:
            s.quit()

Или можно захотеть что-то асинхронное :

import asyncio
from aiosmtpd.controller import Controller

class CustomHandler:
    async def handle_DATA(self, server, session, envelope):
        peer = session.peer
        mail_from = envelope.mail_from
        rcpt_tos = envelope.rcpt_tos
        data = envelope.content         # type: bytes

        # Process message data...

        if error_occurred:
            return '500 Could not process your message'
        return '250 OK'

handler = CustomHandler()
controller = Controller(handler, hostname='127.0.0.1', port=10025)
controller.start()

Конечно, самый популярный почтовик - это Exchange и ему закон не писан, так как у них свое SDK на C#.

 

Эта система называется Mail Transport Agent.

Несмотря на это, вполне реально встроить еще одно звено на Python:

namespace MyAgents
{
    public sealed class MyAgentFactory : RoutingAgentFactory
    {
        public override RoutingAgent CreateAgent(SmtpServer server)
        {
            return new MyAgent();
        }
    }
    public class MyAgent : RoutingAgent
    {
        public MyAgent()
        {
            this.OnSubmittedMessage += new OnSubmittedMessageEventHandler(MyHandler);
        }
        private void MyHandler(SubmittedMessageEventSource source, QueuedMessageEventArgs e)

        {
            // Здесь мы отправляем запрос Python ноде
            // Получаем ответ
            // Принимаем решение
        }
    }

Достаточно просто выстроить систему управления:

 

  • SubmittedMessageEventSource source позволяет сделать с письмом что угодно (удалить, изменить получателей и т. д.)
  • QueuedMessageEventArgs e содержит MailItem, который содержит объект над письмом, откуда можно получить любую информацию.

Парсинг не без сложностей, но нормальный:

>>> import email
>>> with open('/home/nikiladonya/text.eml', 'rb') as f:
...     data = f.read()
... 
>>> msg = email.message_from_bytes(data)
>>> msg.is_multipart()
True
>>> msg.get_content_type()
'multipart/mixed'
>>> msg['Content-Type']
'multipart/mixed; boundary="f403045ea57ae3da7905579c563a"'
>>> msg.get_payload()
[<email.message.Message object at 0x7efd58a5c828>, <email.message.Message object at 0x7efd58a5cbe0>]
>>> msg.get_payload()[0].get_payload()[0].get_content_type()
'text/plain'
>>> msg.get_payload()[0].get_payload()[0].get_payload()
'4byxzrXPgc6/zrPOu8+Nz4bOv8+CDQrZh9mK2YTZiNiMINioDQpIZWxsbw0KDQotLSANCtChINGD\n0LLQsNC20LXQvdC40LXQvCwNCtCd0LjQutC40YLQsCDQm9Cw0LTQvtGI0LrQuNC9DQo='
>>> msg.get_payload()[0].get_payload()[0]['Content-Transfer-Encoding']
'base64'
>>> msg.get_payload()[0].get_payload()[0].get_content_charset()
'utf-8'
>>> import base64
>>> msg.get_payload()[0].get_payload()[0].get_payload()
'4byxzrXPgc6/zrPOu8+Nz4bOv8+CDQrZh9mK2YTZiNiMINioDQpIZWxsbw0KDQotLSANCtChINGD\n0LLQsNC20LXQvdC40LXQvCwNCtCd0LjQutC40YLQsCDQm9Cw0LTQvtGI0LrQuNC9DQo='
>>> print(base64.b64decode(msg.get_payload()[0].get_payload()[0].get_payload()).decode())
Hello
-- 
С уважением,
Никита Ладошкин