Testing secure email transmitting locally

I inherited some legacy Python code that sends emails the following way (code simplified):

message = MIMEMultipart()
message["From"] = email_from
message["To"] = email_to
message["Subject"] = email_subject

message.attach(MIMEText(email_msg, "plain"))
context = ssl.create_default_context()

with smtplib.SMTP(os.getenv("SMTP_HOST"), os.getenv("SMTP_PORT")) as server:
    server.ehlo()
    server.starttls(context=context)
    server.ehlo()
    server.login(os.getenv("SMTP_USER"), os.getenv("SMTP_PASSWORD"))
    server.sendmail(email_from, email_to, message.as_string())
    server.quit()

I wanted to run and test this code without modifying it at this point and without using the actual mail server. That's why I set off to find some locally runnable email server solutions that fit well within the docker-compose stack I was already using.

The most promising servers I found were the following:

Both come with convenient Docker images that run out of the box and have documentation for the most important configurations. They also feature nice web front ends to view the received emails that don't differ that much on first sight and the REST-API to request this data has all the needed information.

In the end I settled on MailHog as it had the most recent stable version (summer 2020 vs. summer 2019).

Unfortunately what both servers also have in common is that they don't support SSH and STARTTLS that I need to use as you can see from the code snippet above. Both have feature requests in their repositories about this functionality but there seems to be no activity in this regard. Browsing through those tickets I stumbled about recommendations to use a reverse proxy to work around this problem and there was one specific mention of Simple Mail Forwarder and I decided to give it a try.

Though I take security very seriously it is not my strongest subject and I'm always happy if solutions exist that set things up securely by default and provide the needed documentation to fill the gaps as needed.

In this case I had to bite my way through it and probably spent way too much time on it. In the end I got it working and here I want to protocol how I got there.

Getting the MailHog container running

This step is fairly easy: Add this to your services inside docker-compose.yml and define the environment variables in your .env file:

email_test_server:
  container_name: ${COMPOSE_PROJECT_NAME}_email_test_server
  image: mailhog/mailhog:v1.0.1
  ports:
    - ${PUBLISH_PORT_EMAIL_TEST_SERVER_FRONTEND}:8025
  networks:
    - default

It actually exposes the ports 8025 (web interface over HTTP) and 1025 (SMTP server). As I only need to access the later from within the container network I didn't forward it in the ports section. Otherwise I didn't need to change much and am immediately able to access the front end and the API that are both unsecured.

Creating the glue

The next step is to get the Simple Mail Forwarder (SMF) to work and now it's getting interesting. I made this iteratively as I was unsure how the certificate generation would work. So I started it up with the following setup:

email_test_forwarder:
  container_name: ${COMPOSE_PROJECT_NAME}_email_test_forwarder
  image: zixia/simple-mail-forwarder:1.4
  depends_on:
    - email_test_server
  environment:
    SMF_DOMAIN: email_test_forwarder
    SMF_CONFIG: "@email_test_forwarder:to@email_test_server:testPw"
    SMF_RELAYHOST: email_test_server:1025
    TZ: Europe/Berlin
  networks:
    - default

This creates the SMTP user @email_test_forwarder with password testPw and forwards every message received that is directed to the @email_test_forwarder domain to to@email_test_server. During the setup process SMF complains about the domain names without a top level domain. This might be solved by using something like @email_test_forwarder.local but I didn't try it out as it worked anyway.

SFM takes care of generating self-signed certificates for you if don't provide it some yourself. The important thing here is setting SMF_DOMAIN correctly.

When you run this image in your container it works well. But when I ran the Python code to connect to the server I got the following error message:

[SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: self signed certificate (_ssl.c:1091)

That's reasonable: By default Python doesn't want to connect to a server using a self-signed certificate. So I need to install it in the Python container.

For figuring out what to do next executing the following command from within the Python container was helpful:

openssl s_client -starttls smtp -connect HOST_EMAIL:SECURE_PORT

or in my case:
openssl s_client -starttls smtp -connect email_test_forwarder:25

It returns among other things the server certificate. I identified this certificate as the one generated as /etc/postfix/cert/smtp.ec.cert inside the email_test_forwarder container. To share this file easily I pulled it out from the forwarder container and saved it (and smtp.cert) in a new data/smtp_test_cert/ directory.

Now to reuse those saved certificates I based a simple Dockerfile on the original one and copied those files over:

FROM zixia/simple-mail-forwarder:1.4

COPY data/smtp_test_cert/* /etc/postfix/cert/

Additionally I changed the section in my docker-compose.yml:

email_test_forwarder:
  container_name: ${COMPOSE_PROJECT_NAME}_email_test_forwarder
  build:
    dockerfile: email_test_forwarder/Dockerfile
  depends_on:
    - email_test_server
  environment:
    SMF_DOMAIN: email_test_forwarder
    SMF_CONFIG: "@email_test_forwarder:to@email_test_server:testPw"
    SMF_RELAYHOST: email_test_server:1025
    TZ: Europe/Berlin
  networks:
    - default

Adjusting the Python container

At last I have to install those certificates in my Python docker container. This can be achieved by a small addition to the respective Dockerfile:

FROM python:3.7

[...]
COPY data/smtp_test_cert/*.cert /etc/ssl/certs/
RUN  ln -s /etc/ssl/certs/smtp.ec.cert /etc/ssl/certs/36cc48aa.0
[...]

The hash that needs to be used for the link (in this case 36cc48aa.0) can be found by executing the following command and appending ".0" to it.

openssl x509 -noout -hash -in /etc/ssl/certs/smtp.ec.cert

The only thing that is left to do is configuring the SMTP connection with the following values:

SMTP_HOST=email_test_forwarder
SMTP_PORT=25

# for user info see SMF_CONFIG in docker-compose.yml
SMTP_USER=@email_test_forwarder
SMTP_PASSWORD=testPw

SMTP_FROM=zzz@email_test_forwarder

Now I can test the application without having to worry about external mail servers.

impressum