Skip to content

Commit 7f9e20f

Browse files
feat: add pytest tests, GitHub Actions CI, STARTTLS, DKIM/DMARC docs
- Add 25+ pytest test cases covering all API endpoints (accounts, token, messages, batch, domains, auth edge cases) - Add GitHub Actions CI workflow (ruff lint + pytest) - Add SMTP STARTTLS support with optional TLS cert/key mounting - Add DKIM/DMARC DNS configuration guide to both READMEs - Add ruff.toml for consistent linting config - Add requirements-dev.txt for test dependencies
1 parent ecd7d33 commit 7f9e20f

11 files changed

Lines changed: 451 additions & 6 deletions

File tree

.env.example

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,10 @@ API_PORT=8080
1111
SMTP_PORT=25
1212
MESSAGE_TTL_DAYS=3
1313

14+
# TLS / STARTTLS (optional - mount certs in docker-compose.yml)
15+
# SMTP_TLS_CERT=/certs/fullchain.pem
16+
# SMTP_TLS_KEY=/certs/privkey.pem
17+
1418
# ============================================================
1519
# Mail Viewer (Web UI)
1620
# ============================================================

.github/workflows/ci.yml

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
name: CI
2+
3+
on:
4+
push:
5+
branches: [master]
6+
pull_request:
7+
branches: [master]
8+
9+
jobs:
10+
lint:
11+
name: Lint
12+
runs-on: ubuntu-latest
13+
steps:
14+
- uses: actions/checkout@v4
15+
16+
- uses: actions/setup-python@v5
17+
with:
18+
python-version: "3.11"
19+
20+
- name: Install ruff
21+
run: pip install ruff
22+
23+
- name: Lint mail-service
24+
run: ruff check mail-service/
25+
26+
- name: Lint mail-viewer
27+
run: ruff check mail-viewer/app.py
28+
29+
test:
30+
name: Test
31+
runs-on: ubuntu-latest
32+
defaults:
33+
run:
34+
working-directory: mail-service
35+
steps:
36+
- uses: actions/checkout@v4
37+
38+
- uses: actions/setup-python@v5
39+
with:
40+
python-version: "3.11"
41+
42+
- name: Install dependencies
43+
run: pip install -r requirements-dev.txt
44+
45+
- name: Run tests
46+
run: pytest tests/ -v --tb=short

README.md

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -123,10 +123,18 @@ yourdomain.com. IN MX 10 mail.yourdomain.com.
123123
; A record — points to your server IP
124124
mail.yourdomain.com. IN A <your-server-ip>
125125
126-
; SPF record (recommended)
126+
; SPF record — declares which IPs may send for your domain
127127
yourdomain.com. IN TXT "v=spf1 ip4:<your-server-ip> -all"
128+
129+
; DKIM record — email signature verification (generate key pair first)
130+
default._domainkey.yourdomain.com. IN TXT "v=DKIM1; k=rsa; p=<your-public-key>"
131+
132+
; DMARC record — policy for failed SPF/DKIM checks
133+
_dmarc.yourdomain.com. IN TXT "v=DMARC1; p=quarantine; rua=mailto:dmarc@yourdomain.com"
128134
```
129135

136+
> **STARTTLS**: ManyMail supports STARTTLS. Mount your TLS certificate and key via `docker-compose.yml` and set `SMTP_TLS_CERT` / `SMTP_TLS_KEY` in `.env`. See `.env.example` for details.
137+
130138
<br>
131139

132140
## API Reference

README_CN.md

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -123,10 +123,18 @@ yourdomain.com. IN MX 10 mail.yourdomain.com.
123123
; A 记录 — 指向你的服务器 IP
124124
mail.yourdomain.com. IN A <你的服务器IP>
125125
126-
; SPF 记录(推荐,防止伪造发件人)
126+
; SPF 记录 — 声明哪些 IP 可以代表你的域名发信
127127
yourdomain.com. IN TXT "v=spf1 ip4:<你的服务器IP> -all"
128+
129+
; DKIM 记录 — 邮件签名验证(需先生成密钥对)
130+
default._domainkey.yourdomain.com. IN TXT "v=DKIM1; k=rsa; p=<你的公钥>"
131+
132+
; DMARC 记录 — SPF/DKIM 验证失败时的处理策略
133+
_dmarc.yourdomain.com. IN TXT "v=DMARC1; p=quarantine; rua=mailto:dmarc@yourdomain.com"
128134
```
129135

136+
> **STARTTLS**:ManyMail 支持 STARTTLS 加密传输。在 `docker-compose.yml` 中挂载 TLS 证书,并在 `.env` 中设置 `SMTP_TLS_CERT` / `SMTP_TLS_KEY`。详见 `.env.example`
137+
130138
<br>
131139

132140
## API 参考

docker-compose.yml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,12 @@ services:
4949
- SMTP_GREYLIST_ENABLED=0
5050
- SMTP_GREYLIST_DELAY_SECONDS=60
5151
- SMTP_GREYLIST_TTL_SECONDS=3600
52+
- SMTP_TLS_CERT=${SMTP_TLS_CERT:-}
53+
- SMTP_TLS_KEY=${SMTP_TLS_KEY:-}
54+
# Uncomment to mount TLS certificates for STARTTLS:
55+
# volumes:
56+
# - /etc/letsencrypt/live/mail.yourdomain.com/fullchain.pem:/certs/fullchain.pem:ro
57+
# - /etc/letsencrypt/live/mail.yourdomain.com/privkey.pem:/certs/privkey.pem:ro
5258
depends_on:
5359
mongodb:
5460
condition: service_healthy

mail-service/app.py

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,8 @@
4040
_SEED_DOMAINS = [d.strip().lower() for d in os.getenv("DOMAINS", "").split(",") if d.strip()]
4141
API_PORT = int(os.getenv("API_PORT", "8080"))
4242
SMTP_PORT = int(os.getenv("SMTP_PORT", "25"))
43+
SMTP_TLS_CERT = os.getenv("SMTP_TLS_CERT", "") # path to TLS certificate (PEM)
44+
SMTP_TLS_KEY = os.getenv("SMTP_TLS_KEY", "") # path to TLS private key (PEM)
4345
MESSAGE_TTL_DAYS = int(os.getenv("MESSAGE_TTL_DAYS", "3"))
4446

4547

@@ -910,18 +912,37 @@ async def handle_DATA(self, server, session, envelope):
910912
return "451 Requested action aborted: error in processing"
911913

912914

915+
def _build_tls_context():
916+
"""Build SSLContext for STARTTLS if cert/key are configured."""
917+
if not SMTP_TLS_CERT or not SMTP_TLS_KEY:
918+
return None
919+
import ssl
920+
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
921+
ctx.minimum_version = ssl.TLSVersion.TLSv1_2
922+
ctx.load_cert_chain(SMTP_TLS_CERT, SMTP_TLS_KEY)
923+
logger.info(f"TLS context loaded: cert={SMTP_TLS_CERT}")
924+
return ctx
925+
926+
913927
def start_smtp_server():
914-
"""启动 SMTP 服务器 (后台守护线程)"""
928+
"""启动 SMTP 服务器 (后台守护线程),支持 STARTTLS"""
915929
handler = MailHandler()
916-
controller = Controller(
917-
handler,
930+
tls_ctx = _build_tls_context()
931+
932+
kwargs = dict(
918933
hostname="0.0.0.0",
919934
port=SMTP_PORT,
920935
server_hostname=SMTP_HOSTNAME,
921936
data_size_limit=10 * 1024 * 1024, # 10MB
922937
)
938+
if tls_ctx:
939+
kwargs["tls_context"] = tls_ctx
940+
kwargs["require_starttls"] = False # offer but don't require
941+
942+
controller = Controller(handler, **kwargs)
923943
controller.start()
924-
logger.info(f"SMTP server started on port {SMTP_PORT}")
944+
tls_status = "STARTTLS enabled" if tls_ctx else "no TLS"
945+
logger.info(f"SMTP server started on port {SMTP_PORT} ({tls_status})")
925946
logger.info(f"Accepting mail for domains: {get_active_domains()} (dynamic from DB)")
926947

927948

mail-service/requirements-dev.txt

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
-r requirements.txt
2+
pytest>=7.0
3+
httpx>=0.24.0
4+
mongomock>=4.1.0
5+
ruff>=0.4.0

mail-service/tests/__init__.py

Whitespace-only changes.

mail-service/tests/conftest.py

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
"""
2+
Shared pytest fixtures for mail-service tests.
3+
4+
Uses mongomock to avoid requiring a real MongoDB instance.
5+
"""
6+
7+
import os
8+
9+
# Set test environment BEFORE importing app
10+
os.environ.update({
11+
"MONGO_URL": "mongomock://localhost",
12+
"DB_NAME": "test_mailserver",
13+
"JWT_SECRET": "test-secret-key-for-ci",
14+
"API_KEY": "test-api-key",
15+
"SMTP_HOSTNAME": "mail.test.local",
16+
"DOMAINS": "test.local,example.test",
17+
"ENVIRONMENT": "development",
18+
"RATE_LIMIT_MAX": "0", # disable rate limiting in tests
19+
})
20+
21+
import pytest
22+
from unittest.mock import patch, MagicMock
23+
import mongomock
24+
from fastapi.testclient import TestClient
25+
26+
27+
@pytest.fixture(autouse=True)
28+
def mock_mongo():
29+
"""Replace pymongo with mongomock for all tests."""
30+
mock_client = mongomock.MongoClient()
31+
mock_db = mock_client["test_mailserver"]
32+
33+
with patch("app.mongo_client", mock_client), \
34+
patch("app.db", mock_db):
35+
# Re-init indexes
36+
from app import init_db
37+
init_db()
38+
yield mock_db
39+
40+
mock_client.close()
41+
42+
43+
@pytest.fixture(autouse=True)
44+
def mock_smtp():
45+
"""Prevent SMTP server from actually starting during tests."""
46+
with patch("app.start_smtp_server"):
47+
yield
48+
49+
50+
@pytest.fixture
51+
def client(mock_mongo, mock_smtp):
52+
"""FastAPI test client."""
53+
from app import app
54+
with TestClient(app) as c:
55+
yield c
56+
57+
58+
@pytest.fixture
59+
def test_account(client):
60+
"""Create a test account and return (address, password, token)."""
61+
address = "testuser@test.local"
62+
password = "testpass123"
63+
64+
resp = client.post("/accounts", json={"address": address, "password": password})
65+
assert resp.status_code == 201
66+
67+
resp = client.post("/token", json={"address": address, "password": password})
68+
assert resp.status_code == 200
69+
token = resp.json()["token"]
70+
71+
return address, password, token
72+
73+
74+
@pytest.fixture
75+
def auth_header(test_account):
76+
"""Authorization header for authenticated requests."""
77+
_, _, token = test_account
78+
return {"Authorization": f"Bearer {token}"}
79+
80+
81+
@pytest.fixture
82+
def sample_message(mock_mongo, test_account):
83+
"""Insert a sample message into the DB and return its ID."""
84+
from datetime import datetime, timezone
85+
from bson import ObjectId
86+
87+
address = test_account[0]
88+
msg_id = ObjectId()
89+
mock_mongo.messages.insert_one({
90+
"_id": msg_id,
91+
"to_addresses": [address],
92+
"from": {"address": "sender@external.com", "name": "Test Sender"},
93+
"to": [{"address": address, "name": ""}],
94+
"subject": "Test Subject",
95+
"intro": "This is a test email preview",
96+
"text": "Hello, this is a test email body.",
97+
"html": "<p>Hello, this is a <b>test</b> email body.</p>",
98+
"has_attachments": False,
99+
"seen": False,
100+
"is_deleted": False,
101+
"size": 256,
102+
"created_at": datetime.now(timezone.utc),
103+
"updated_at": datetime.now(timezone.utc),
104+
})
105+
return str(msg_id)

0 commit comments

Comments
 (0)