Versions used
- Python 3.5
- Django 1.10
- Django Rest Framework 3.7
- IMAP IMAP4rev1
Why an SMTP API
For us, the need for an e-mail interface emerged while developing backoffice tools and interfacing with partners, to track service requests and interface business worflows and backoffice tools together.
In our experience this is a very common need in our trade, and too often have we battled to avoid such interfaces and finally implemented them anyway. The result has always been a buggy interface, and us developers blaming the idea of an e-mail interface in itself.
So this time, we decided to approach the issue with a more positive attitude. In fact an SMTP API is a fertile idea if you need your system to interact with a number of different external partners and some of them prefer a simple e-mail. This makes sense because an e-mail combines notification, communication and display, provides broadcast, transfer and reply mechanisms, and e-mail softwares include many organizational features (filters, flags, snoozes, …).
For example, all major tracking systems (JIRA, Mantis, Redmine, …) have an SMTP interface that sends e-mails, but also accept incoming e-mails and handle them automatically.
Finally, interfacing Web APIs with multiple partners can raise issues in terms of infrastructure and security. The good thing about e-mails is: everyone has them. There will be no firewall issues, no need for a VPN installation or other networking issues.
Design
UI tend to be built upon an API also used for machine to machine interactions. The API is mutualized, this reduces code size and cyclomatic complexity and hence the probability of bugs. This also helps on the testing side, as API tests will also cover a larger part of the UI execution paths.
- POSIX
- The POSIX standard, among other things, sets the Command Line Interface as the common protocol for UI and API and provides standard I/O commands to interface software together (I/O redirection, pipe).
- Web Apps
- Web UIs are often built over a SOAP or REST API, with the HTTP protocol as a common underlying layer.
- SMTP
- It makes sense to provide a more "machine friendly" API alongside the UI, for the same design reasons it makes sense for command line interfaces or Web apps. Even more so if some external partners want e-mails and others want to interface their own software with yours.
History
The 70's ARPANET file and message protocols are the ancestors of FTP and SMTP.
- 1974 : TCP standard
- 1981: SMTP standard
- 1983: FTP is adapted for TCP
- 1996: HTTP standard
For a long time, Remote Procedure Calls were designed to work on a local, proprietary network and SMTP was the only way for distant communications or communications between different computer brands.
A guide to Python SMTP API
SMTP offers a really nice set of extensions known as MIME (RFC2045 & RFC2046). We try to build upon these norms to provide a nice API over SMTP.
Output
We listed the following requirements for our e-mail interface:
- The mail content can be configured by the user; bleach this content to avoid injection.
- The user writes a template with placeholders for the data.
- The HTML version of the e-mail uses a common base (CSS, logo, etc.).
- The e-mail has a plain text alternative matching the HTML content.
- The e-mail has a JSON or YAML alternative convenient for machine interfaces.
The template could look like this:
Dear partner, Please check vehicle {{vehicle.plate_number}} located {{vehicle.address}}. Kind regards,
So we decided to use the possibility of mime/alternative content:
- An application/json alternative contains all the data
- A Markdown or reST template is used to build the text/plain alternative
- The HTML version is injected into an HTML/CSS template for the text/html alternative
Note
There are no JSON or YAML text subtypes, so we use the application/json type. Sadly, this makes the JSON appear as an attachment in some applications, eg. GMail
- UTF-8
- We chose to always use UTF-8 for character encoding, as this is the standard default.
- Custom identifier
- If the e-mail is linked to an internal resource, we need an identifier for traceability. As advised in RFC6648, we chose to refrain from using custom headers. The message ID is not a good fit either, because we can have multiple e-mails for a single resource. As most people tend to do, we decided to include our identifier in the Reply-To address:
From: mail-api@polyconseil.fr Reply-To: mail-api+a74e22bb-3313-4dc4-af28-6c2bd1351c0f@polyconseil.fr To: third-party@example.com Subject: [a74e22bb] An Example Message-ID: <mail-api-e4fa4b17-424f-47e2-93a3-58454c60f869@polyconseil.fr> Refers-To: <mail-api-e4fa4b17-424f-47e2-93a3-58454c60f869@polyconseil.fr>
- Subject
- We use the Refers-To header to let mail applications group messages refering to the same issue together. However, some mail applications will group conversations depending on the subject, date and sender, and will not use the Refers-To header. We include some distinctive text in the subject to avoid having different e-mails grouped together if they're not meant to. We use a short version of our custom identifier for this purpose.
- Security
- S/MIME is the way to go if we want to provide a security layer on our e-mail interface. This is the only standard out there; we could consider using GPG, but never a custom system.
- Attachments
- MIME offers an extensive typing system for attachments and lets us transfer documents, photos, videos or even geolocalizations.
This is how our mail generation code looks:
import bleach import django.conf.settings import django.core.mail import django.template.engines import markdown import rest_framework.renderers def mail(email_address, subject, serializer, mail_template): message = django.core.mail.EmailMultiAlternatives( from_email='{}@{}'.format( django.conf.settings.APP_NAME, django.conf.settings.EMAIL_HOSTNAME, ), # use a unique ID for your item - eg. the instance PK reply_to=[ '{}+{}@{}'.format( django.conf.settings.APP_NAME, serializer.instance.pk, django.conf.settings.EMAIL_HOSTNAME, ) ], to=email_address, subject=subject, ) # order alternatives carefully: last is preferred message.attach_alternative( rest_framework.renderers.JSONRenderer().render(serializer.data), 'application/json' ) # here we use the "django" engine, but another one could do body = bleach.clean( django.template.engines['django'] .from_string(mail_template) .render(serializer.data) ) # alternatively, we could try the text/markdown MIME type (RFC 7763) message.attach_alternative(body, 'text/plain') message.attach_alternative( django.template.engines['django'].get_template('email_base.html').render({ 'reply_to': reply_to, # the mail_template is supposed to be in markdown format 'body': markdown.markdown(body), }), 'text/html' ) message.send()
Input
An SMTP interface should be easy to use for a human being, as Jon Postel said: "Be liberal in what you accept, and conservative in what you send". After all the guy invented SMTP in the first place, so his advice is worth considering.
- Case
- We try to parse any text in a case insensitive way as people often fail to use upper or lower case as required.
- Languages
- As our software is available in multiple languages, we feel our e-mail interface should also accept any of these languages as input.
- Punctuation and structure
- We decided to consider all punctuations and spaces as a single separator, as this avoids encoding bugs and typos. When we need more structure, we use a light standard format like YAML or INI.
- Alternatives
- We always try to work it out from an HTML or plain text alternative, even if having a clear application/json part is the most reliable. Some partners use software or interfaces not able to produce a clean application/json part. Some people using the system manually send a single HTML part with no plain text alternative.
import email import json import re import yaml import bs4.BeautifulSoup def process_message(data): message = email.message_from_bytes(data) attachments = [] json_part = text_part = html_part = '' for part in message.walk(): try: if part.is_multipart(): continue # sub-parts are iterated over in this walk payload = part.get_payload(decode=True) if part.get_content_type() == 'application/json': json_part = json.loads(payload.decode('utf-8')) if part.get_content_maintype() == 'text': payload = payload.decode(part.get_content_charset() or 'utf-8') # if multiple text/plain parts are given, concatenate if part.get_content_subtype() == 'plain': text_part += payload # get plain text version from HTML using BeautifulSoup if part.get_content_subtype() == 'html': soup = bs4.BeautifulSoup(payload, 'html.parser') for item in soup(["script", "style"]): item.extract() html_part += '\n' + '\n'.join( line.strip() for line in soup.get_text().split('\n') if line ) # images, videos, PDFs and vendor-specific files are valid attachments if ( part.get_content_maintype() in ('image', 'video') or part.get_content_type() == 'application/pdf' or ( part.get_content_maintype() == 'application' and part.get_content_subtype().startswith('vnd.') ) ): attachments.append((part.get_content_type(), payload)) except Exception: continue # parsing e-mail is hard; log and adapt incrementally # use HTML as plain text if none was found text_part = text_part or html_part # remove signature match = re.search(r'^--\s*$', text_part, re.MULTILINE) if match: text_part = text_part[:match.start()].strip() # use plain text as JSON if none was found if not json_part: try: # using a yaml stream allows the user to write some JSON or YAML, then # use the '---' document separator to provide more unformatted text json_part = next(yaml.safe_load_all(text_part)) except Exception: pass
IMAP
Some inputs do fail because, you know, PEBCAK. We want our operators to be able to access the mailbox manually and fix the situation. Relying on an existing e-mail server and leaving the mails on it seemed the easiest way.
This means IMAP. Our software connects to the server, processes e-mails and moves them to a designated folder depending on the result (success or failure of processing).
- Use UIDs
- Message IDs are not stable on a mail server: they change as messages get moved, copied or deleted. We always use the UID version of the IMAP commands.
- Close the connection after use
- Some IMAP servers will reject new connections if we don't close old ones properly.
- Copy & delete instead of move
- Some interfaces don't implement the UID MOVE capability correctly: we tested it, but needed to copy and delete instead.
- Using a proxy
- At some point our organisation switched to an external IMAP server. Our infrastructure team set up a proxy for us to connect through. We devised an elegant way to use Python imaplib and http libraries for this (example below).
import http import imaplib class ImapServer(imaplib.IMAP4_SSL): def __init__( self, host='', port=imaplib.IMAP4_SSL_PORT, proxy_host='', proxy_port=imaplib.IMAP4_SSL_PORT, ): self.proxy_host = proxy_host self.proxy_port = proxy_port super().__init__(host, port) def _create_socket(self): if self.proxy_host: connection = http.client.HTTPSConnection( host=self.proxy_host, port=self.proxy_port, ) connection.settunnel(self.host, self.port) connection.connect() return connection.sock return super()._create_socket() def check(self, result): retval, data = result if retval != 'OK': raise self.error('IMAP error: %s' % data[-1] if data else '') return data def get(self, result): return self.check(result)[0] def unlist(self, result): return self.get(result).decode('ascii').split() def fetch_mails(): """Fetch mails from IMAP server INBOX. Messages are moved to 'processed' on success, or 'failed' otherwise. """ imap_server = imap_connect() try: imap_server.check(imap_server.select('INBOX')) messages = imap_server.unlist(imap_server.uid('search', None, 'ALL')) for message in messages: data = imap_server.get(imap_server.uid('fetch', message, 'BODY.PEEK[]')) try: process_message(data[1]) except Exception: imap_server.uid('copy', message, 'failed') imap_server.uid('store', message, '+FLAGS', r'(\Deleted)') else: imap_server.uid('copy', message, 'processed') imap_server.uid('store', message, '+FLAGS', r'(\Deleted)') except Exception: pass # log error finally: imap_server.close() imap_server.logout()
Conclusion
Parsing incoming e-mails is challenging. Multiple iterations are needed to achieve a stable code and, even then, we still monitor this interface and improve it over time, adapt to the most common mistakes human operators do and to the limitations of legacy softwares we want to interface with. This code could, in our opinion, benefit from open-source developments and we are now considering writing a more generic version of our code to publish a first version of a library for this purpose.