chore: sync GovOPlaN module split state
This commit is contained in:
11
dev/mail-testbed/.env.example
Normal file
11
dev/mail-testbed/.env.example
Normal file
@@ -0,0 +1,11 @@
|
||||
GOVOPLAN_MAIL_TEST_LOCAL_PART=campaign-test
|
||||
GOVOPLAN_MAIL_TEST_DOMAIN=govoplan.test
|
||||
GOVOPLAN_MAIL_TEST_USER=campaign-test@govoplan.test
|
||||
GOVOPLAN_MAIL_TEST_PASSWORD=campaign-test-password
|
||||
GOVOPLAN_MAIL_TEST_RECIPIENT=campaign-test@govoplan.test
|
||||
GOVOPLAN_MAIL_TEST_FROM=campaign-test@govoplan.test
|
||||
GOVOPLAN_MAIL_TEST_SMTP_PORT=3025
|
||||
GOVOPLAN_MAIL_TEST_IMAP_PORT=3143
|
||||
GOVOPLAN_MAIL_TEST_SENT_FOLDER=Sent
|
||||
GOVOPLAN_MAIL_TEST_ZIP_PASSWORD=zip-test-password
|
||||
GOVOPLAN_MAIL_TEST_READY_TIMEOUT_SECONDS=45
|
||||
69
dev/mail-testbed/README.md
Normal file
69
dev/mail-testbed/README.md
Normal file
@@ -0,0 +1,69 @@
|
||||
# Campaign SMTP/IMAP Test Bed
|
||||
|
||||
This test bed provides dedicated non-production SMTP/IMAP infrastructure for
|
||||
campaign delivery checks. It uses GreenMail and must never be configured with
|
||||
production recipients or credentials.
|
||||
|
||||
## Start
|
||||
|
||||
```bash
|
||||
cd /mnt/DATA/git/govoplan-campaign/dev/mail-testbed
|
||||
cp .env.example .env
|
||||
docker compose --env-file .env up -d
|
||||
```
|
||||
|
||||
Default endpoints:
|
||||
|
||||
- SMTP: `127.0.0.1:3025`, plain transport
|
||||
- IMAP: `127.0.0.1:3143`, plain transport
|
||||
- GreenMail API/UI endpoint: `127.0.0.1:38080`
|
||||
|
||||
## Smoke Test
|
||||
|
||||
Run the transport smoke from the core virtual environment after installing the
|
||||
campaign and mail modules:
|
||||
|
||||
```bash
|
||||
cd /mnt/DATA/git/govoplan-campaign/dev/mail-testbed
|
||||
set -a
|
||||
. ./.env
|
||||
set +a
|
||||
/mnt/DATA/git/govoplan-core/.venv/bin/python run_transport_smoke.py
|
||||
```
|
||||
|
||||
The smoke sends and appends three messages:
|
||||
|
||||
- no attachment
|
||||
- one normal attachment
|
||||
- one password-protected AES ZIP attachment
|
||||
|
||||
It verifies SMTP authentication, IMAP authentication, folder listing, delivery
|
||||
to `INBOX`, and append-to-Sent behavior.
|
||||
|
||||
If the smoke is started immediately after `docker compose up -d`, GreenMail may
|
||||
bind the SMTP/IMAP ports before the services are fully ready. The smoke retries
|
||||
login and folder setup for `GOVOPLAN_MAIL_TEST_READY_TIMEOUT_SECONDS`.
|
||||
|
||||
## Use With A Campaign
|
||||
|
||||
Use the same settings in a campaign mail profile:
|
||||
|
||||
- SMTP host `127.0.0.1`, port `3025`, security `plain`
|
||||
- IMAP host `127.0.0.1`, port `3143`, security `plain`
|
||||
- username and password from `.env`
|
||||
- append folder from `GOVOPLAN_MAIL_TEST_SENT_FOLDER`
|
||||
|
||||
When changing the mailbox, keep `GOVOPLAN_MAIL_TEST_USER` equal to
|
||||
`${GOVOPLAN_MAIL_TEST_LOCAL_PART}@${GOVOPLAN_MAIL_TEST_DOMAIN}` because the
|
||||
compose file creates the GreenMail account from local part, password, and
|
||||
domain while login uses the full email address.
|
||||
|
||||
Then run the controlled sending checklist from
|
||||
`docs/CAMPAIGN_DELIVERY_RUNBOOK.md` for no attachment, one attachment, and
|
||||
password-protected ZIP campaign variants.
|
||||
|
||||
## Stop
|
||||
|
||||
```bash
|
||||
docker compose --env-file .env down -v
|
||||
```
|
||||
17
dev/mail-testbed/docker-compose.yml
Normal file
17
dev/mail-testbed/docker-compose.yml
Normal file
@@ -0,0 +1,17 @@
|
||||
services:
|
||||
greenmail:
|
||||
image: greenmail/standalone:2.1.9
|
||||
container_name: govoplan-campaign-mail-testbed
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
GREENMAIL_OPTS: >-
|
||||
-Dgreenmail.setup.test.all
|
||||
-Dgreenmail.hostname=0.0.0.0
|
||||
-Dgreenmail.auth.disabled=false
|
||||
-Dgreenmail.users=${GOVOPLAN_MAIL_TEST_LOCAL_PART:-campaign-test}:${GOVOPLAN_MAIL_TEST_PASSWORD:-campaign-test-password}@${GOVOPLAN_MAIL_TEST_DOMAIN:-govoplan.test}
|
||||
-Dgreenmail.users.login=email
|
||||
-Dgreenmail.verbose
|
||||
ports:
|
||||
- "${GOVOPLAN_MAIL_TEST_SMTP_PORT:-3025}:3025"
|
||||
- "${GOVOPLAN_MAIL_TEST_IMAP_PORT:-3143}:3143"
|
||||
- "127.0.0.1:38080:8080"
|
||||
167
dev/mail-testbed/run_transport_smoke.py
Normal file
167
dev/mail-testbed/run_transport_smoke.py
Normal file
@@ -0,0 +1,167 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import imaplib
|
||||
import mimetypes
|
||||
import os
|
||||
import tempfile
|
||||
import time
|
||||
from email.message import EmailMessage
|
||||
from email.utils import formatdate, make_msgid
|
||||
from pathlib import Path
|
||||
|
||||
from govoplan_mail.backend.config import ImapConfig, SmtpConfig, TransportSecurity
|
||||
from govoplan_mail.backend.sending.imap import append_message_to_sent, list_imap_folders, list_imap_messages, test_imap_login
|
||||
from govoplan_mail.backend.sending.smtp import send_email_message, test_smtp_login
|
||||
|
||||
from govoplan_campaign.backend.services.zip_service import create_zip_archive
|
||||
|
||||
|
||||
def _env(name: str, default: str) -> str:
|
||||
return os.environ.get(name, default).strip() or default
|
||||
|
||||
|
||||
SMTP_HOST = _env("GOVOPLAN_MAIL_TEST_SMTP_HOST", "127.0.0.1")
|
||||
IMAP_HOST = _env("GOVOPLAN_MAIL_TEST_IMAP_HOST", "127.0.0.1")
|
||||
SMTP_PORT = int(_env("GOVOPLAN_MAIL_TEST_SMTP_PORT", "3025"))
|
||||
IMAP_PORT = int(_env("GOVOPLAN_MAIL_TEST_IMAP_PORT", "3143"))
|
||||
USERNAME = _env("GOVOPLAN_MAIL_TEST_USER", "campaign-test@govoplan.test")
|
||||
PASSWORD = _env("GOVOPLAN_MAIL_TEST_PASSWORD", "campaign-test-password")
|
||||
SENDER = _env("GOVOPLAN_MAIL_TEST_FROM", USERNAME)
|
||||
RECIPIENT = _env("GOVOPLAN_MAIL_TEST_RECIPIENT", USERNAME)
|
||||
SENT_FOLDER = _env("GOVOPLAN_MAIL_TEST_SENT_FOLDER", "Sent")
|
||||
ZIP_PASSWORD = _env("GOVOPLAN_MAIL_TEST_ZIP_PASSWORD", "zip-test-password")
|
||||
SERVICE_READY_TIMEOUT_SECONDS = int(_env("GOVOPLAN_MAIL_TEST_READY_TIMEOUT_SECONDS", "45"))
|
||||
|
||||
|
||||
def _smtp_config() -> SmtpConfig:
|
||||
return SmtpConfig(
|
||||
host=SMTP_HOST,
|
||||
port=SMTP_PORT,
|
||||
security=TransportSecurity.PLAIN,
|
||||
username=USERNAME,
|
||||
password=PASSWORD,
|
||||
timeout_seconds=10,
|
||||
)
|
||||
|
||||
|
||||
def _imap_config() -> ImapConfig:
|
||||
return ImapConfig(
|
||||
host=IMAP_HOST,
|
||||
port=IMAP_PORT,
|
||||
security=TransportSecurity.PLAIN,
|
||||
username=USERNAME,
|
||||
password=PASSWORD,
|
||||
sent_folder=SENT_FOLDER,
|
||||
timeout_seconds=10,
|
||||
)
|
||||
|
||||
|
||||
def _message(subject: str, body: str, attachments: list[Path] | None = None) -> EmailMessage:
|
||||
msg = EmailMessage()
|
||||
msg["From"] = SENDER
|
||||
msg["To"] = RECIPIENT
|
||||
msg["Subject"] = subject
|
||||
msg["Date"] = formatdate(localtime=False)
|
||||
msg["Message-ID"] = make_msgid(domain="govoplan.test")
|
||||
msg.set_content(body)
|
||||
|
||||
for attachment in attachments or []:
|
||||
content_type = mimetypes.guess_type(attachment.name)[0] or "application/octet-stream"
|
||||
maintype, subtype = content_type.split("/", 1)
|
||||
msg.add_attachment(attachment.read_bytes(), maintype=maintype, subtype=subtype, filename=attachment.name)
|
||||
return msg
|
||||
|
||||
|
||||
def _ensure_folder(config: ImapConfig, folder: str) -> None:
|
||||
client = imaplib.IMAP4(host=config.host or "", port=config.port or 143, timeout=config.timeout_seconds)
|
||||
try:
|
||||
if config.username and config.password:
|
||||
client.login(config.username, config.password)
|
||||
typ, data = client.create(folder)
|
||||
if typ not in {"OK", "NO"}:
|
||||
raise RuntimeError(f"Could not create IMAP folder {folder!r}: {data!r}")
|
||||
finally:
|
||||
try:
|
||||
client.logout()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _wait_for_delivery(config: ImapConfig, *, before_count: int, expected_increment: int) -> None:
|
||||
deadline = time.monotonic() + 15
|
||||
last_count = before_count
|
||||
while time.monotonic() < deadline:
|
||||
result = list_imap_messages(imap_config=config, folder="INBOX", limit=10)
|
||||
last_count = result.total_count
|
||||
if last_count >= before_count + expected_increment:
|
||||
return
|
||||
time.sleep(0.5)
|
||||
raise RuntimeError(
|
||||
f"Expected at least {expected_increment} new INBOX message(s), "
|
||||
f"but count moved from {before_count} to {last_count}."
|
||||
)
|
||||
|
||||
|
||||
def _wait_for_service(label: str, check):
|
||||
deadline = time.monotonic() + SERVICE_READY_TIMEOUT_SECONDS
|
||||
last_error: Exception | None = None
|
||||
while time.monotonic() < deadline:
|
||||
try:
|
||||
return check()
|
||||
except Exception as exc: # GreenMail can bind before SMTP/IMAP is ready.
|
||||
last_error = exc
|
||||
time.sleep(1)
|
||||
raise RuntimeError(f"{label} did not become ready within {SERVICE_READY_TIMEOUT_SECONDS}s: {last_error}") from last_error
|
||||
|
||||
|
||||
def main() -> int:
|
||||
smtp = _smtp_config()
|
||||
imap = _imap_config()
|
||||
|
||||
smtp_login = _wait_for_service("SMTP", lambda: test_smtp_login(smtp_config=smtp))
|
||||
imap_login = _wait_for_service("IMAP", lambda: test_imap_login(imap_config=imap))
|
||||
print(f"SMTP login OK: {smtp_login.host}:{smtp_login.port} authenticated={smtp_login.authenticated}")
|
||||
print(f"IMAP login OK: {imap_login.host}:{imap_login.port} authenticated={imap_login.authenticated}")
|
||||
|
||||
_wait_for_service("IMAP folder creation", lambda: _ensure_folder(imap, SENT_FOLDER))
|
||||
folders = _wait_for_service("IMAP folder listing", lambda: list_imap_folders(imap_config=imap))
|
||||
print("IMAP folders: " + ", ".join(folder.name for folder in folders.folders))
|
||||
|
||||
before_inbox = list_imap_messages(imap_config=imap, folder="INBOX", limit=10).total_count
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix="govoplan-campaign-mail-test-") as tmp:
|
||||
root = Path(tmp)
|
||||
attachment = root / "single-attachment.txt"
|
||||
attachment.write_text("GovOPlaN single attachment smoke test\n", encoding="utf-8")
|
||||
zip_source = root / "zip-source.txt"
|
||||
zip_source.write_text("GovOPlaN password-protected ZIP smoke test\n", encoding="utf-8")
|
||||
zip_path = create_zip_archive(root / "password-protected.zip", [zip_source], ZIP_PASSWORD)
|
||||
|
||||
messages = [
|
||||
_message("[GovOPlaN test] no attachment", "No attachment SMTP/IMAP smoke."),
|
||||
_message("[GovOPlaN test] one attachment", "One attachment SMTP/IMAP smoke.", [attachment]),
|
||||
_message("[GovOPlaN test] password ZIP", "Password-protected ZIP SMTP/IMAP smoke.", [zip_path]),
|
||||
]
|
||||
|
||||
for message in messages:
|
||||
result = send_email_message(
|
||||
message,
|
||||
smtp_config=smtp,
|
||||
envelope_from=SENDER,
|
||||
envelope_recipients=[RECIPIENT],
|
||||
)
|
||||
print(f"SMTP accepted {result.accepted_count} recipient(s): {message['Subject']}")
|
||||
append = append_message_to_sent(bytes(message), imap_config=imap, folder=SENT_FOLDER)
|
||||
print(f"IMAP appended {append.bytes_appended} byte(s) to {append.folder}: {message['Subject']}")
|
||||
|
||||
_wait_for_delivery(imap, before_count=before_inbox, expected_increment=3)
|
||||
sent_count = list_imap_messages(imap_config=imap, folder=SENT_FOLDER, limit=10).total_count
|
||||
if sent_count < 3:
|
||||
raise RuntimeError(f"Expected at least 3 messages in {SENT_FOLDER!r}, found {sent_count}.")
|
||||
print("Transport smoke OK: no attachment, one attachment, and password-protected ZIP messages verified.")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user