inital commit
This commit is contained in:
226
server/app/mailer/commands/send_test_message.py
Normal file
226
server/app/mailer/commands/send_test_message.py
Normal 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())
|
||||
Reference in New Issue
Block a user