Skip to content

Commit cfce31d

Browse files
committed
feat(modules): Mailpit Container
1 parent 01d6c18 commit cfce31d

File tree

5 files changed

+1381
-1017
lines changed

5 files changed

+1381
-1017
lines changed
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
.. autoclass:: testcontainers.mailpit.MailpitContainer
2+
.. title:: testcontainers.mailpit.MailpitContainer
Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
#
2+
# Licensed under the Apache License, Version 2.0 (the "License"); you may
3+
# not use this file except in compliance with the License. You may obtain
4+
# a copy of the License at
5+
#
6+
# http://www.apache.org/licenses/LICENSE-2.0
7+
#
8+
# Unless required by applicable law or agreed to in writing, software
9+
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
10+
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
11+
# License for the specific language governing permissions and limitations
12+
# under the License.
13+
import logging
14+
import os
15+
import tempfile
16+
from datetime import UTC, datetime, timedelta
17+
from typing import NamedTuple, Self
18+
19+
from cryptography import x509
20+
from cryptography.hazmat.primitives import hashes, serialization
21+
from cryptography.hazmat.primitives.asymmetric import rsa
22+
from cryptography.hazmat.primitives.serialization import (
23+
NoEncryption,
24+
)
25+
from cryptography.x509.oid import NameOID
26+
27+
from testcontainers.core.container import DockerContainer
28+
from testcontainers.core.waiting_utils import wait_for_logs
29+
30+
logger = logging.getLogger(__name__)
31+
32+
33+
class MailpitUser(NamedTuple):
34+
username: str
35+
password: str
36+
37+
38+
class MailpitContainer(DockerContainer): # type: ignore[misc]
39+
"""
40+
Test container for Mailpit. The example below spins up a Mailpit server
41+
42+
Default configuration supports SMTP with STARTTLS and allows login with any
43+
user/password.
44+
45+
Options:
46+
- require_tls = True forces the use of SSL
47+
- users = [MailpitUser("jane", "secret"), MailpitUser("ron", "pass2")] only
48+
allows login with jane:secret or ron:pass2
49+
50+
Example:
51+
52+
.. doctest::
53+
54+
>>> import smtplib
55+
56+
>>> from testcontainers.mailpit import MailpitContainer
57+
58+
>>> with MailpitContainer() as mailpit_container:
59+
... host_ip = mailpit_container.get_container_host_ip()
60+
... host_port = mailpit_container.get_exposed_smtp_port()
61+
... server = smtplib.SMTP(
62+
... mailpit_container.get_container_host_ip(),
63+
... mailpit_container.get_exposed_smtp_port(),
64+
... )
65+
... code, _ = server.login("any", "auth")
66+
... assert code == 235 # authentication successful
67+
... # use server.sendmail(...) to send emails
68+
69+
"""
70+
71+
def __init__( # type: ignore[no-untyped-def]
72+
self,
73+
image: str = "axllent/mailpit",
74+
*,
75+
smtp_port: int = 1025,
76+
ui_port: int = 8025,
77+
users: list[MailpitUser] | None = None,
78+
require_tls: bool = False,
79+
**kwargs,
80+
) -> None:
81+
super().__init__(image=image, **kwargs)
82+
self.smtp_port = smtp_port
83+
self.ui_port = ui_port
84+
85+
self.users = users if users is not None else []
86+
self.auth_accept_any = int(len(self.users) == 0)
87+
88+
self.require_tls = int(require_tls)
89+
self.tls_key, self.tls_cert = _generate_tls_certificates()
90+
with tempfile.NamedTemporaryFile(delete=False, delete_on_close=False) as tls_key_file:
91+
tls_key_file.write(self.tls_key)
92+
self.tls_key_file = tls_key_file.name
93+
94+
with tempfile.NamedTemporaryFile(delete=False, delete_on_close=False) as tls_cert_file:
95+
tls_cert_file.write(self.tls_cert)
96+
self.tls_cert_file = tls_cert_file.name
97+
98+
@property
99+
def _users_conf(self) -> str:
100+
"""Mailpit user configuration string
101+
102+
"user:password user2:pass2 ...]
103+
"""
104+
return " ".join(f"{user.username}:{user.password}" for user in self.users)
105+
106+
def _configure(self) -> None:
107+
if self.users:
108+
self.with_env("MP_SMTP_AUTH", self._users_conf)
109+
self.with_env("MP_SMTP_AUTH_ACCEPT_ANY", str(self.auth_accept_any))
110+
111+
self.with_env("MP_SMTP_REQUIRE_TLS", str(self.require_tls))
112+
113+
self.with_volume_mapping(self.tls_cert_file, "/cert.pem")
114+
self.with_volume_mapping(self.tls_key_file, "/key.pem")
115+
self.with_env("MP_SMTP_TLS_CERT", "/cert.pem")
116+
self.with_env("MP_SMTP_TLS_KEY", "/key.pem")
117+
118+
self.with_exposed_ports(self.smtp_port, self.ui_port)
119+
120+
def start(self) -> Self:
121+
super().start()
122+
wait_for_logs(self, ".*accessible via.*")
123+
return self
124+
125+
def stop(self, *args, **kwargs) -> None:
126+
super().stop(*args, **kwargs)
127+
os.remove(self.tls_key_file)
128+
os.remove(self.tls_cert_file)
129+
130+
def get_exposed_smtp_port(self) -> int:
131+
return int(self.get_exposed_port(self.smtp_port))
132+
133+
134+
class _TLSCertificates(NamedTuple):
135+
private_key: bytes
136+
certificate: bytes
137+
138+
139+
def _generate_tls_certificates() -> _TLSCertificates:
140+
"""Generate self-signed TLS certificates as bytes"""
141+
private_key = _generate_private_key()
142+
certificate = _generate_self_signed_certificate(private_key)
143+
144+
private_key_bytes = private_key.private_bytes(
145+
encoding=serialization.Encoding.PEM,
146+
format=serialization.PrivateFormat.TraditionalOpenSSL,
147+
encryption_algorithm=NoEncryption(),
148+
)
149+
certificate_bytes = certificate.public_bytes(serialization.Encoding.PEM)
150+
151+
return _TLSCertificates(private_key_bytes, certificate_bytes)
152+
153+
154+
def _generate_private_key() -> rsa.RSAPrivateKey:
155+
"""Generate RSA private key"""
156+
return rsa.generate_private_key(
157+
public_exponent=65537,
158+
key_size=4096,
159+
)
160+
161+
162+
def _generate_self_signed_certificate(
163+
private_key: rsa.RSAPrivateKey,
164+
) -> x509.Certificate:
165+
"""Generate self-signed certificate with RSA private key"""
166+
domain = "mydomain.com"
167+
subject = issuer = x509.Name(
168+
[
169+
x509.NameAttribute(NameOID.COUNTRY_NAME, "US"),
170+
x509.NameAttribute(NameOID.STATE_OR_PROVINCE_NAME, "California"),
171+
x509.NameAttribute(NameOID.LOCALITY_NAME, "San Francisco"),
172+
x509.NameAttribute(NameOID.ORGANIZATION_NAME, "The Post Office"),
173+
x509.NameAttribute(NameOID.COMMON_NAME, domain),
174+
]
175+
)
176+
177+
return (
178+
x509.CertificateBuilder()
179+
.subject_name(subject)
180+
.issuer_name(issuer)
181+
.public_key(private_key.public_key())
182+
.serial_number(x509.random_serial_number())
183+
.not_valid_before(datetime.now(UTC))
184+
.not_valid_after(datetime.now(UTC) + timedelta(days=3650)) # 10 years
185+
.add_extension(
186+
x509.SubjectAlternativeName([x509.DNSName(domain)]),
187+
critical=False,
188+
)
189+
.sign(private_key, hashes.SHA256())
190+
)
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
import smtplib
2+
from email.mime.text import MIMEText
3+
from email.mime.multipart import MIMEMultipart
4+
5+
import pytest
6+
7+
from testcontainers.mailpit import MailpitContainer, MailpitUser
8+
9+
_sender = "[email protected]"
10+
_receivers = ["[email protected]"]
11+
_msg = MIMEMultipart("mixed")
12+
_msg["From"] = _sender
13+
_msg["To"] = ", ".join(_receivers)
14+
_msg["Subject"] = "test"
15+
_msg.attach(MIMEText("test", "plain"))
16+
_sendmail_args = (_sender, _receivers, _msg.as_string())
17+
18+
19+
def test_mailpit_basic():
20+
config = MailpitContainer()
21+
with config as mailpit:
22+
server = smtplib.SMTP(
23+
mailpit.get_container_host_ip(),
24+
mailpit.get_exposed_smtp_port(),
25+
)
26+
server.login("any", "auth")
27+
server.sendmail(*_sendmail_args)
28+
29+
30+
def test_mailpit_starttls():
31+
config = MailpitContainer()
32+
with config as mailpit:
33+
server = smtplib.SMTP(
34+
mailpit.get_container_host_ip(),
35+
mailpit.get_exposed_smtp_port(),
36+
)
37+
server.starttls()
38+
server.login("any", "auth")
39+
server.sendmail(*_sendmail_args)
40+
41+
42+
def test_mailpit_force_tls():
43+
config = MailpitContainer(require_tls=True)
44+
with config as mailpit:
45+
server = smtplib.SMTP_SSL(
46+
mailpit.get_container_host_ip(),
47+
mailpit.get_exposed_smtp_port(),
48+
)
49+
server.login("any", "auth")
50+
server.sendmail(*_sendmail_args)
51+
52+
53+
def test_mailpit_basic_with_users_pass_auth():
54+
users = [MailpitUser("user", "password")]
55+
config = MailpitContainer(users=users)
56+
with config as mailpit:
57+
server = smtplib.SMTP(
58+
mailpit.get_container_host_ip(),
59+
mailpit.get_exposed_smtp_port(),
60+
)
61+
server.login(mailpit.users[0].username, mailpit.users[0].password)
62+
server.sendmail(*_sendmail_args)
63+
64+
65+
def test_mailpit_basic_with_users_fail_auth():
66+
users = [MailpitUser("user", "password")]
67+
config = MailpitContainer(users=users)
68+
with pytest.raises(smtplib.SMTPAuthenticationError):
69+
with config as mailpit:
70+
server = smtplib.SMTP(
71+
mailpit.get_container_host_ip(),
72+
mailpit.get_exposed_smtp_port(),
73+
)
74+
server.login("not", "good")
75+
76+
77+
def test_mailpit_starttls_with_users_pass_auth():
78+
users = [MailpitUser("user", "password")]
79+
config = MailpitContainer(users=users)
80+
with config as mailpit:
81+
server = smtplib.SMTP(
82+
mailpit.get_container_host_ip(),
83+
mailpit.get_exposed_smtp_port(),
84+
)
85+
server.starttls()
86+
server.login(mailpit.users[0].username, mailpit.users[0].password)
87+
server.sendmail(*_sendmail_args)
88+
89+
90+
def test_mailpit_starttls_with_users_fail_auth():
91+
users = [MailpitUser("user", "password")]
92+
config = MailpitContainer(users=users)
93+
with pytest.raises(smtplib.SMTPAuthenticationError):
94+
with config as mailpit:
95+
server = smtplib.SMTP(
96+
mailpit.get_container_host_ip(),
97+
mailpit.get_exposed_smtp_port(),
98+
)
99+
server.starttls()
100+
server.login("not", "good")
101+
102+
103+
def test_mailpit_force_tls_with_users_pass_auth():
104+
users = [MailpitUser("user", "password")]
105+
config = MailpitContainer(users=users, require_tls=True)
106+
with config as mailpit:
107+
server = smtplib.SMTP_SSL(
108+
mailpit.get_container_host_ip(),
109+
mailpit.get_exposed_smtp_port(),
110+
)
111+
server.login(mailpit.users[0].username, mailpit.users[0].password)
112+
server.sendmail(*_sendmail_args)
113+
114+
115+
def test_mailpit_force_tls_with_users_fail_auth():
116+
users = [MailpitUser("user", "password")]
117+
config = MailpitContainer(users=users, require_tls=True)
118+
with pytest.raises(smtplib.SMTPAuthenticationError):
119+
with config as mailpit:
120+
server = smtplib.SMTP_SSL(
121+
mailpit.get_container_host_ip(),
122+
mailpit.get_exposed_smtp_port(),
123+
)
124+
server.login("not", "good")

0 commit comments

Comments
 (0)