Skip to content

Commit 0b866ff

Browse files
oliverlambsonalexanderankin
andauthoredJun 30, 2024··
fix(modules): Mailpit Container (#625)
# New Container Fixes #626 # PR Checklist - [x] Your PR title follows the [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/) syntax as we make use of this for detecting Semantic Versioning changes. - [x] Your PR allows maintainers to edit your branch, this will speed up resolving minor issues! - [x] The new container is implemented under `modules/*` - Your module follows [PEP 420](https://peps.python.org/pep-0420/) with implicit namespace packages (if unsure, look at other existing community modules) - Your package namespacing follows `testcontainers.<modulename>.*` and you DO NOT have an `__init__.py` above your module's level. - Your module has it's own tests under `modules/*/tests` - Your module has a `README.rst` and hooks in the `.. auto-class` and `.. title` of your container - Implement the new feature (typically in `__init__.py`) and corresponding tests. - [x] Your module is added in `pyproject.toml` - it is declared under `tool.poetry.packages` - see other community modules - it is declared under `tool.poetry.extras` with the same name as your module name, we still prefer adding _NO EXTRA DEPENDENCIES_, meaning `mymodule = []` is the preferred addition (see the notes at the bottom) - [x] (seems to not be needed anymore) The `INDEX.rst` at the project root includes your module under the `.. toctree` directive - [x] Your branch is up to date (or we'll use GH's "update branch" function through the UI) --------- Co-authored-by: Dave Ankin <daveankin@gmail.com>
1 parent 01d6c18 commit 0b866ff

File tree

7 files changed

+378
-1
lines changed

7 files changed

+378
-1
lines changed
 

‎.github/settings.yml

+1
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ labels:
7171
- { name: '📦 package: google', color: '#0052CC', description: '' }
7272
- { name: '📦 package: kafka', color: '#0052CC', description: '' }
7373
- { name: '📦 package: keycloak', color: '#0052CC', description: '' }
74+
- { name: '📦 package: mailpit', color: '#0052CC', description: '' }
7475
- { name: '📦 package: mongodb', color: '#0052CC', description: '' }
7576
- { name: '📦 package: mssql', color: '#0052CC', description: '' }
7677
- { name: '📦 package: neo4j', color: '#0052CC', description: '' }

‎modules/mailpit/README.rst

+3
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
.. autoclass:: testcontainers.mailpit.MailpitUser
2+
.. autoclass:: testcontainers.mailpit.MailpitContainer
3+
.. title:: testcontainers.mailpit.MailpitContainer
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,243 @@
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+
from __future__ import annotations
14+
15+
import os
16+
import tempfile
17+
from datetime import datetime, timedelta, timezone
18+
from typing import TYPE_CHECKING, Any, NamedTuple
19+
20+
from cryptography import x509
21+
from cryptography.hazmat.primitives import hashes, serialization
22+
from cryptography.hazmat.primitives.asymmetric import rsa
23+
from cryptography.hazmat.primitives.serialization import (
24+
NoEncryption,
25+
)
26+
from cryptography.x509.oid import NameOID
27+
28+
from testcontainers.core.container import DockerContainer
29+
from testcontainers.core.waiting_utils import wait_for_logs
30+
31+
if TYPE_CHECKING:
32+
from typing_extensions import Self
33+
34+
35+
class MailpitUser(NamedTuple):
36+
"""Mailpit user for authentication
37+
38+
Helper class to define a user for Mailpit authentication.
39+
40+
This is just a named tuple for username and password.
41+
42+
43+
Example:
44+
45+
.. doctest::
46+
47+
>>> from testcontainers.mailpit import MailpitUser
48+
49+
>>> users = [
50+
... MailpitUser("jane", "secret"),
51+
... MailpitUser("ron", "pass2"),
52+
... ]
53+
54+
>>> for user in users:
55+
... print(user.username, user.password)
56+
...
57+
jane secret
58+
ron pass2
59+
60+
>>> username, password = users[0]
61+
62+
>>> print(username, password)
63+
jane secret
64+
"""
65+
66+
username: str
67+
password: str
68+
69+
70+
class MailpitContainer(DockerContainer):
71+
"""
72+
Test container for Mailpit. The example below spins up a Mailpit server
73+
74+
Default configuration supports SMTP with STARTTLS and allows login with any
75+
user/password.
76+
77+
Options:
78+
79+
* ``require_tls = True`` forces the use of SSL
80+
* ``users = [MailpitUser("jane", "secret"), MailpitUser("ron", "pass2")]`` \
81+
only allows login with ``jane:secret`` or ``ron:pass2``
82+
83+
Simple example:
84+
85+
.. doctest::
86+
87+
>>> import smtplib
88+
89+
>>> from testcontainers.mailpit import MailpitContainer
90+
91+
>>> with MailpitContainer() as mailpit_container:
92+
... host_ip = mailpit_container.get_container_host_ip()
93+
... host_port = mailpit_container.get_exposed_smtp_port()
94+
... server = smtplib.SMTP(
95+
... mailpit_container.get_container_host_ip(),
96+
... mailpit_container.get_exposed_smtp_port(),
97+
... )
98+
... code, _ = server.login("any", "auth")
99+
... assert code == 235 # authentication successful
100+
... # use server.sendmail(...) to send emails
101+
102+
Example with auth and forced TLS:
103+
104+
.. doctest::
105+
106+
>>> import smtplib
107+
108+
>>> from testcontainers.mailpit import MailpitContainer, MailpitUser
109+
110+
>>> users = [MailpitUser("jane", "secret"), MailpitUser("ron", "pass2")]
111+
112+
>>> with MailpitContainer(users=users, require_tls=True) as mailpit_container:
113+
... host_ip = mailpit_container.get_container_host_ip()
114+
... host_port = mailpit_container.get_exposed_smtp_port()
115+
... server = smtplib.SMTP_SSL(
116+
... mailpit_container.get_container_host_ip(),
117+
... mailpit_container.get_exposed_smtp_port(),
118+
... )
119+
... code, _ = server.login("jane", "secret")
120+
... assert code == 235 # authentication successful
121+
... # use server.sendmail(...) to send emails
122+
"""
123+
124+
def __init__(
125+
self,
126+
image: str = "axllent/mailpit",
127+
*,
128+
smtp_port: int = 1025,
129+
ui_port: int = 8025,
130+
users: list[MailpitUser] | None = None,
131+
require_tls: bool = False,
132+
**kwargs: Any,
133+
) -> None:
134+
super().__init__(image=image, **kwargs)
135+
self.smtp_port = smtp_port
136+
self.ui_port = ui_port
137+
138+
self.users = users if users is not None else []
139+
self.auth_accept_any = int(len(self.users) == 0)
140+
141+
self.require_tls = int(require_tls)
142+
self.tls_key, self.tls_cert = _generate_tls_certificates()
143+
with tempfile.NamedTemporaryFile(delete=False) as tls_key_file:
144+
tls_key_file.write(self.tls_key)
145+
self.tls_key_file = tls_key_file.name
146+
147+
with tempfile.NamedTemporaryFile(delete=False) as tls_cert_file:
148+
tls_cert_file.write(self.tls_cert)
149+
self.tls_cert_file = tls_cert_file.name
150+
151+
@property
152+
def _users_conf(self) -> str:
153+
"""Mailpit user configuration string
154+
155+
"user:password user2:pass2 ...]
156+
"""
157+
return " ".join(f"{user.username}:{user.password}" for user in self.users)
158+
159+
def _configure(self) -> None:
160+
if self.users:
161+
self.with_env("MP_SMTP_AUTH", self._users_conf)
162+
self.with_env("MP_SMTP_AUTH_ACCEPT_ANY", str(self.auth_accept_any))
163+
164+
self.with_env("MP_SMTP_REQUIRE_TLS", str(self.require_tls))
165+
166+
self.with_volume_mapping(self.tls_cert_file, "/cert.pem")
167+
self.with_volume_mapping(self.tls_key_file, "/key.pem")
168+
self.with_env("MP_SMTP_TLS_CERT", "/cert.pem")
169+
self.with_env("MP_SMTP_TLS_KEY", "/key.pem")
170+
171+
self.with_exposed_ports(self.smtp_port, self.ui_port)
172+
173+
def start(self) -> Self:
174+
super().start()
175+
wait_for_logs(self, ".*accessible via.*")
176+
return self
177+
178+
def stop(self, *args: Any, **kwargs: Any) -> None:
179+
super().stop(*args, **kwargs)
180+
os.remove(self.tls_key_file)
181+
os.remove(self.tls_cert_file)
182+
183+
def get_exposed_smtp_port(self) -> int:
184+
return int(self.get_exposed_port(self.smtp_port))
185+
186+
187+
class _TLSCertificates(NamedTuple):
188+
private_key: bytes
189+
certificate: bytes
190+
191+
192+
def _generate_tls_certificates() -> _TLSCertificates:
193+
"""Generate self-signed TLS certificates as bytes"""
194+
private_key = _generate_private_key()
195+
certificate = _generate_self_signed_certificate(private_key)
196+
197+
private_key_bytes = private_key.private_bytes(
198+
encoding=serialization.Encoding.PEM,
199+
format=serialization.PrivateFormat.TraditionalOpenSSL,
200+
encryption_algorithm=NoEncryption(),
201+
)
202+
certificate_bytes = certificate.public_bytes(serialization.Encoding.PEM)
203+
204+
return _TLSCertificates(private_key_bytes, certificate_bytes)
205+
206+
207+
def _generate_private_key() -> rsa.RSAPrivateKey:
208+
"""Generate RSA private key"""
209+
return rsa.generate_private_key(
210+
public_exponent=65537,
211+
key_size=4096,
212+
)
213+
214+
215+
def _generate_self_signed_certificate(
216+
private_key: rsa.RSAPrivateKey,
217+
) -> x509.Certificate:
218+
"""Generate self-signed certificate with RSA private key"""
219+
domain = "mydomain.com"
220+
subject = issuer = x509.Name(
221+
[
222+
x509.NameAttribute(NameOID.COUNTRY_NAME, "US"),
223+
x509.NameAttribute(NameOID.STATE_OR_PROVINCE_NAME, "California"),
224+
x509.NameAttribute(NameOID.LOCALITY_NAME, "San Francisco"),
225+
x509.NameAttribute(NameOID.ORGANIZATION_NAME, "The Post Office"),
226+
x509.NameAttribute(NameOID.COMMON_NAME, domain),
227+
]
228+
)
229+
230+
return (
231+
x509.CertificateBuilder()
232+
.subject_name(subject)
233+
.issuer_name(issuer)
234+
.public_key(private_key.public_key())
235+
.serial_number(x509.random_serial_number())
236+
.not_valid_before(datetime.now(timezone.utc))
237+
.not_valid_after(datetime.now(timezone.utc) + timedelta(days=3650)) # 10 years
238+
.add_extension(
239+
x509.SubjectAlternativeName([x509.DNSName(domain)]),
240+
critical=False,
241+
)
242+
.sign(private_key, hashes.SHA256())
243+
)

‎modules/mailpit/testcontainers/mailpit/py.typed

Whitespace-only changes.

‎modules/mailpit/tests/test_mailpit.py

+124
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 = "from@example.com"
10+
_receivers = ["to@example.com"]
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")

‎poetry.lock

+3-1
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎pyproject.toml

+4
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ packages = [
4444
{ include = "testcontainers", from = "modules/kafka" },
4545
{ include = "testcontainers", from = "modules/keycloak" },
4646
{ include = "testcontainers", from = "modules/localstack" },
47+
{ include = "testcontainers", from = "modules/mailpit" },
4748
{ include = "testcontainers", from = "modules/memcached" },
4849
{ include = "testcontainers", from = "modules/minio" },
4950
{ include = "testcontainers", from = "modules/milvus" },
@@ -108,6 +109,7 @@ qdrant-client = { version = "*", optional = true }
108109
bcrypt = { version = "*", optional = true }
109110
httpx = { version = "*", optional = true }
110111
azure-cosmos = { version = "*", optional = true }
112+
cryptography = { version = "*", optional = true }
111113

112114
[tool.poetry.extras]
113115
arangodb = ["python-arango"]
@@ -125,6 +127,7 @@ k3s = ["kubernetes", "pyyaml"]
125127
kafka = []
126128
keycloak = ["python-keycloak"]
127129
localstack = ["boto3"]
130+
mailpit = ["cryptography"]
128131
memcached = []
129132
minio = ["minio"]
130133
milvus = []
@@ -276,6 +279,7 @@ mypy_path = [
276279
# "modules/kafka",
277280
# "modules/keycloak",
278281
# "modules/localstack",
282+
"modules/mailpit",
279283
# "modules/minio",
280284
# "modules/mongodb",
281285
# "modules/mssql",

0 commit comments

Comments
 (0)
Please sign in to comment.