inital commit

This commit is contained in:
2026-06-08 15:57:11 +02:00
parent aaf8729663
commit d9ca48addc
114 changed files with 12172 additions and 1 deletions

View File

@@ -0,0 +1,226 @@
from __future__ import annotations
import argparse
import getpass
import json
import os
import sys
from pathlib import Path
from app.mailer.campaign.entries import EntryLoadError
from app.mailer.campaign.loader import CampaignLoadError, load_campaign_config
from app.mailer.campaign.models import SmtpConfig, TransportSecurity
from app.mailer.messages.builder import BuiltMessage, build_campaign_messages
from app.mailer.messages.models import MessageDraft
from app.mailer.sending.smtp import (
SmtpConfigurationError,
SmtpSendError,
prepare_test_message,
send_email_message,
)
def _env(name: str) -> str | None:
value = os.getenv(name)
return value if value not in {None, ""} else None
def _parse_security(value: str | None, fallback: TransportSecurity) -> TransportSecurity:
if not value:
return fallback
try:
return TransportSecurity(value.lower())
except ValueError as exc:
allowed = ", ".join(item.value for item in TransportSecurity)
raise ValueError(f"invalid SMTP security '{value}', expected one of: {allowed}") from exc
def _parse_port(value: str | None, fallback: int | None) -> int | None:
if not value:
return fallback
try:
return int(value)
except ValueError as exc:
raise ValueError(f"invalid SMTP port '{value}'") from exc
def _smtp_config_with_overrides(args: argparse.Namespace, base: SmtpConfig | None) -> SmtpConfig:
config = base or SmtpConfig()
password = args.smtp_password
if args.smtp_password_env:
password = _env(args.smtp_password_env)
if password is None:
raise ValueError(f"environment variable {args.smtp_password_env} is empty or not set")
if args.ask_password:
password = getpass.getpass("SMTP password: ")
return SmtpConfig(
host=args.smtp_host or _env("MULTIMAILER_SMTP_HOST") or config.host,
port=_parse_port(args.smtp_port or _env("MULTIMAILER_SMTP_PORT"), config.port),
username=args.smtp_username or _env("MULTIMAILER_SMTP_USERNAME") or config.username,
password=password or _env("MULTIMAILER_SMTP_PASSWORD") or config.password,
security=_parse_security(args.smtp_security or _env("MULTIMAILER_SMTP_SECURITY"), config.security),
timeout_seconds=args.smtp_timeout or config.timeout_seconds,
)
def _select_message(
messages: list[BuiltMessage],
*,
entry_id: str | None,
entry_index: int | None,
allow_non_queueable: bool,
) -> BuiltMessage:
candidates = messages
if entry_id is not None:
candidates = [item for item in candidates if item.draft.entry_id == entry_id]
if not candidates:
raise ValueError(f"no generated message found for entry id '{entry_id}'")
if entry_index is not None:
candidates = [item for item in candidates if item.draft.entry_index == entry_index]
if not candidates:
raise ValueError(f"no generated message found for entry index {entry_index}")
if not allow_non_queueable:
queueable = [item for item in candidates if item.draft.is_queueable and item.mime is not None]
if queueable:
return queueable[0]
raise ValueError(
"no queueable built message found. Fix validation issues or pass --allow-non-queueable for a deliberate test send."
)
built = [item for item in candidates if item.mime is not None]
if not built:
raise ValueError("no built MIME message found")
return built[0]
def _envelope_from(draft: MessageDraft) -> str:
if draft.bounce_to:
return draft.bounce_to[0].email
if draft.from_:
return draft.from_.email
raise SmtpConfigurationError("message has no sender; cannot determine SMTP envelope sender")
def _write_test_eml(path: Path, message) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(bytes(message))
def _print_summary(*, draft: MessageDraft, test_to: str, smtp_config: SmtpConfig, envelope_from: str) -> None:
print(f"Entry: {draft.entry_id or '#' + str(draft.entry_index)}")
print(f"Subject: {draft.subject or ''}")
print(f"Original validation: {draft.validation_status.value}")
print(f"Original send status: {draft.send_status.value}")
print(f"Test recipient: {test_to}")
print(f"Envelope sender: {envelope_from}")
print(f"SMTP: {smtp_config.host}:{smtp_config.port} ({smtp_config.security.value})")
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(
description="Send one generated campaign message to a test recipient. Does not mutate campaign/job status."
)
parser.add_argument("--campaign", required=True, help="Path to campaign.json")
parser.add_argument("--to", required=True, help="Test recipient email address. Real campaign recipients are never used.")
parser.add_argument("--to-name", default=None, help="Optional display name for the test recipient")
parser.add_argument("--entry-id", default=None, help="Select a specific entry by id")
parser.add_argument("--entry-index", type=int, default=None, help="Select a specific entry by 1-based index")
parser.add_argument(
"--allow-non-queueable",
action="store_true",
help="Allow test-send of a built message whose validation status is needs_review/warning/blocked/excluded",
)
parser.add_argument("--write-eml", default=None, help="Write the prepared test .eml to this path")
parser.add_argument("--dry-run", action="store_true", help="Build and prepare the test message but do not connect to SMTP")
parser.add_argument("--json", action="store_true", help="Output machine-readable JSON")
parser.add_argument("--smtp-host", default=None, help="Override SMTP host")
parser.add_argument("--smtp-port", default=None, help="Override SMTP port")
parser.add_argument("--smtp-security", default=None, choices=[item.value for item in TransportSecurity], help="Override SMTP security")
parser.add_argument("--smtp-username", default=None, help="Override SMTP username")
parser.add_argument("--smtp-password", default=None, help="Override SMTP password (prefer --smtp-password-env or --ask-password)")
parser.add_argument("--smtp-password-env", default=None, help="Read SMTP password from this environment variable")
parser.add_argument("--ask-password", action="store_true", help="Prompt for SMTP password")
parser.add_argument("--smtp-timeout", type=int, default=None, help="Override SMTP timeout in seconds")
args = parser.parse_args(argv)
campaign_path = Path(args.campaign).resolve()
try:
config = load_campaign_config(campaign_path)
smtp_config = _smtp_config_with_overrides(args, config.server.smtp)
result = build_campaign_messages(config, campaign_file=campaign_path)
selected = _select_message(
result.built_messages,
entry_id=args.entry_id,
entry_index=args.entry_index,
allow_non_queueable=args.allow_non_queueable,
)
assert selected.mime is not None
envelope_from = _envelope_from(selected.draft)
test_message = prepare_test_message(selected.mime, test_recipient=args.to, test_recipient_name=args.to_name)
if args.write_eml:
_write_test_eml(Path(args.write_eml).resolve(), test_message)
if not args.json:
_print_summary(draft=selected.draft, test_to=args.to, smtp_config=smtp_config, envelope_from=envelope_from)
send_result = None
if not args.dry_run:
send_result = send_email_message(
test_message,
smtp_config=smtp_config,
envelope_from=envelope_from,
envelope_recipients=[args.to],
)
except (CampaignLoadError, EntryLoadError, ValueError, SmtpConfigurationError, SmtpSendError, OSError) as exc:
if args.json:
print(json.dumps({"ok": False, "error": str(exc)}, ensure_ascii=False, indent=2))
else:
print(f"Error: {exc}", file=sys.stderr)
return 2
if args.json:
payload = {
"ok": True,
"dry_run": args.dry_run,
"campaign_id": config.campaign.id,
"entry_id": selected.draft.entry_id,
"entry_index": selected.draft.entry_index,
"test_recipient": args.to,
"validation_status": selected.draft.validation_status.value,
"smtp": {
"host": smtp_config.host,
"port": smtp_config.port,
"security": smtp_config.security.value,
},
"send_result": None
if send_result is None
else {
"accepted_count": send_result.accepted_count,
"refused_recipients": send_result.refused_recipients,
},
}
print(json.dumps(payload, ensure_ascii=False, indent=2))
else:
if args.dry_run:
print("Dry run only; no SMTP connection attempted.")
else:
assert send_result is not None
print(f"SMTP accepted recipients: {send_result.accepted_count}/{len(send_result.envelope_recipients)}")
if send_result.refused_recipients:
print(f"SMTP refused recipients: {send_result.refused_recipients}")
else:
print("Test message sent.")
return 0
if __name__ == "__main__":
raise SystemExit(main())