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 @@
"""CLI commands for MultiMailer development workflows."""

View File

@@ -0,0 +1,69 @@
from __future__ import annotations
import argparse
import json
from time import sleep
from app.db.bootstrap import create_all_tables
from app.db.models import CampaignJob, JobImapStatus, JobSendStatus
from app.db.session import SessionLocal
from app.mailer.sending.jobs import append_sent_for_job
from app.security.api_keys import authenticate_api_key
from app.settings import settings
def main() -> None:
parser = argparse.ArgumentParser(description="Append sent campaign messages to the configured IMAP Sent folder.")
parser.add_argument("--campaign-id", required=True, help="Database campaign UUID")
parser.add_argument("--api-key", default=settings.dev_bootstrap_api_key)
parser.add_argument("--limit", type=int, default=0, help="Maximum jobs to process; 0 means all pending/failed IMAP appends")
parser.add_argument("--dry-run", action="store_true")
parser.add_argument("--include-failed", action="store_true", help="Also retry jobs with imap_status=failed")
parser.add_argument("--json", action="store_true")
args = parser.parse_args()
create_all_tables()
results = []
with SessionLocal() as session:
api_key = authenticate_api_key(session, args.api_key)
if not api_key:
raise SystemExit("Invalid API key")
statuses = [JobImapStatus.PENDING.value]
if args.include_failed:
statuses.append(JobImapStatus.FAILED.value)
query = (
session.query(CampaignJob)
.filter(
CampaignJob.tenant_id == api_key.tenant_id,
CampaignJob.campaign_id == args.campaign_id,
CampaignJob.send_status == JobSendStatus.SENT.value,
CampaignJob.imap_status.in_(statuses),
)
.order_by(CampaignJob.entry_index.asc())
)
if args.limit > 0:
query = query.limit(args.limit)
jobs = query.all()
for job in jobs:
try:
result = append_sent_for_job(session, job_id=job.id, dry_run=args.dry_run)
results.append(result.as_dict())
if not args.json:
print(f"{job.entry_index}: {result.status} ({job.recipient_email}) folder={result.folder or '-'}")
except Exception as exc:
results.append({"job_id": job.id, "status": "error", "error": str(exc)})
if not args.json:
print(f"{job.entry_index}: ERROR {exc} ({job.recipient_email})")
sleep(0.1)
if args.json:
print(json.dumps({"processed": len(results), "results": results}, indent=2))
elif not jobs:
print("No pending IMAP append jobs found")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,65 @@
from __future__ import annotations
import argparse
import json
from app.db.bootstrap import create_all_tables
from app.db.models import AuditLog
from app.db.session import SessionLocal
from app.security.api_keys import authenticate_api_key
from app.settings import settings
def _row(item: AuditLog) -> dict:
return {
"id": item.id,
"created_at": item.created_at.isoformat() if item.created_at else None,
"tenant_id": item.tenant_id,
"user_id": item.user_id,
"api_key_id": item.api_key_id,
"action": item.action,
"object_type": item.object_type,
"object_id": item.object_id,
"details": item.details,
}
def main() -> None:
parser = argparse.ArgumentParser(description="List audit log entries.")
parser.add_argument("--api-key", default=settings.dev_bootstrap_api_key)
parser.add_argument("--limit", type=int, default=50)
parser.add_argument("--offset", type=int, default=0)
parser.add_argument("--action")
parser.add_argument("--object-type")
parser.add_argument("--object-id")
parser.add_argument("--json", action="store_true")
args = parser.parse_args()
create_all_tables()
with SessionLocal() as session:
api_key = authenticate_api_key(session, args.api_key)
if not api_key:
raise SystemExit("Invalid API key")
query = session.query(AuditLog).filter(AuditLog.tenant_id == api_key.tenant_id)
if args.action:
query = query.filter(AuditLog.action == args.action)
if args.object_type:
query = query.filter(AuditLog.object_type == args.object_type)
if args.object_id:
query = query.filter(AuditLog.object_id == args.object_id)
items = query.order_by(AuditLog.created_at.desc()).offset(args.offset).limit(args.limit).all()
rows = [_row(item) for item in items]
if args.json:
print(json.dumps({"items": rows}, indent=2, ensure_ascii=False, default=str))
return
for row in rows:
print(
f"{row['created_at']} | {row['action']} | "
f"{row['object_type'] or '-'}:{row['object_id'] or '-'} | {row['details'] or {}}"
)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,99 @@
from __future__ import annotations
import argparse
import json
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.messages.builder import build_campaign_messages
from app.mailer.messages.models import CampaignBuildReport
def _print_report(report: CampaignBuildReport, *, verbose: bool = False) -> None:
print(f"Campaign: {report.campaign_name} ({report.campaign_id})")
print(f"Campaign file: {report.campaign_file}")
print(f"Entries: {report.entries_count}")
print(
"Build: "
f"built={report.built_count}, "
f"failed={report.build_failed_count}, "
f"queueable={report.queueable_count}"
)
print(
"Validation: "
f"ready={report.ready_count}, "
f"warning={report.warning_count}, "
f"needs_review={report.needs_review_count}, "
f"blocked={report.blocked_count}, "
f"excluded={report.excluded_count}, "
f"inactive={report.inactive_count}"
)
for message in report.messages:
print("---")
label = message.entry_id or f"#{message.entry_index}"
eml = f", eml={message.eml_path}" if message.eml_path else ""
print(
f"Entry {label}: "
f"build={message.build_status.value}, "
f"validation={message.validation_status.value}, "
f"send={message.send_status.value}, "
f"imap={message.imap_status.value}, "
f"attachments={message.attachment_count}{eml}"
)
if message.subject:
print(f" Subject: {message.subject}")
if message.to:
print(" To: " + ", ".join(item.email for item in message.to))
for issue in message.issues:
behavior = f", behavior={issue.behavior}" if issue.behavior else ""
source = f", source={issue.source}" if issue.source else ""
print(f" [{issue.severity}] {issue.code}{behavior}{source}: {issue.message}")
if verbose:
for attachment in message.attachments:
print(
f" - attachment {attachment.attachment_id or ''}: "
f"{attachment.status}, matches={len(attachment.matches)}, "
f"zip={attachment.zip_enabled}, filter={attachment.directory}/{attachment.file_filter}"
)
for match in attachment.matches:
print(f" {match}")
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description="Build campaign message drafts and review statuses without sending.")
parser.add_argument("--campaign", required=True, help="Path to campaign.json")
parser.add_argument("--output-dir", default=None, help="Optional directory for generated .eml files")
parser.add_argument("--write-eml", action="store_true", help="Write generated messages as .eml files")
parser.add_argument("--json", action="store_true", help="Output machine-readable JSON report")
parser.add_argument("--verbose", "-v", action="store_true", help="Print attachment details")
args = parser.parse_args(argv)
campaign_path = Path(args.campaign).resolve()
output_dir = Path(args.output_dir).resolve() if args.output_dir else None
write_eml = args.write_eml or output_dir is not None
try:
config = load_campaign_config(campaign_path)
result = build_campaign_messages(
config,
campaign_file=campaign_path,
output_dir=output_dir,
write_eml=write_eml,
)
except (CampaignLoadError, EntryLoadError, ValueError, OSError) as exc:
print(f"Error: {exc}", file=sys.stderr)
return 2
if args.json:
print(json.dumps(result.report.model_dump(mode="json", by_alias=True), ensure_ascii=False, indent=2))
else:
_print_report(result.report, verbose=args.verbose)
return 0 if result.report.build_failed_count == 0 else 1
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,78 @@
from __future__ import annotations
import argparse
import json
from pathlib import Path
from app.db.bootstrap import create_all_tables
from app.db.session import SessionLocal
from app.mailer.reports.campaigns import CampaignReportError, generate_campaign_report, generate_jobs_csv
from app.security.api_keys import authenticate_api_key
from app.settings import settings
def _print_text_report(report: dict) -> None:
campaign = report["campaign"]
cards = report["cards"]
delivery = report["delivery"]
print(f"Campaign: {campaign['name']} ({campaign['id']})")
print(f"Status: {campaign['status']}")
print(f"Jobs: {cards['jobs_total']} total | {cards['queueable']} queueable | {cards['needs_attention']} need attention")
print(f"Sending: {cards['sent']} sent | {cards['failed']} failed")
print(f"IMAP: {cards['imap_appended']} appended | {cards['imap_failed']} failed")
if delivery.get("rate_limit", {}).get("messages_per_minute"):
print(
"Rate: "
f"{delivery['rate_limit']['messages_per_minute']}/min, concurrency {delivery['rate_limit']['concurrency']}"
)
if delivery.get("estimated_remaining_send_human"):
print(f"ETA: {delivery['estimated_remaining_send_human']}")
print("Validation counts:", report["status_counts"]["validation"])
print("Send counts: ", report["status_counts"]["send"])
print("Issue codes: ", report["issues"]["by_code"])
print("Attachments: ", report["attachments"])
failures = report.get("recent_failures") or []
if failures:
print("Recent failures:")
for failure in failures[:10]:
print(
f" - entry={failure['entry_index']} recipient={failure['recipient_email']} "
f"send={failure['send_status']} imap={failure['imap_status']} error={failure['last_error']}"
)
def main() -> None:
parser = argparse.ArgumentParser(description="Generate a campaign status/report payload.")
parser.add_argument("--campaign-id", required=True, help="Database campaign UUID")
parser.add_argument("--api-key", default=settings.dev_bootstrap_api_key)
parser.add_argument("--json", action="store_true", help="Print machine-readable JSON")
parser.add_argument("--include-jobs", action="store_true", help="Include per-job rows in JSON output")
parser.add_argument("--jobs-csv", help="Write per-job report CSV to this path")
args = parser.parse_args()
create_all_tables()
with SessionLocal() as session:
api_key = authenticate_api_key(session, args.api_key)
if not api_key:
raise SystemExit("Invalid API key")
try:
report = generate_campaign_report(
session,
tenant_id=api_key.tenant_id,
campaign_id=args.campaign_id,
include_jobs=args.include_jobs,
)
if args.jobs_csv:
csv_text = generate_jobs_csv(session, tenant_id=api_key.tenant_id, campaign_id=args.campaign_id)
Path(args.jobs_csv).write_text(csv_text, encoding="utf-8")
print(f"Wrote {args.jobs_csv}")
if args.json:
print(json.dumps(report, indent=2, ensure_ascii=False, default=str))
else:
_print_text_report(report)
except CampaignReportError as exc:
raise SystemExit(str(exc)) from exc
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,74 @@
from __future__ import annotations
import argparse
import json
from app.audit.logging import audit_event
from app.db.bootstrap import create_all_tables
from app.db.session import SessionLocal
from app.mailer.reports.emailing import CampaignReportEmailError, send_campaign_report_email
from app.mailer.reports.campaigns import CampaignReportError
from app.security.api_keys import authenticate_api_key
from app.settings import settings
def main() -> None:
parser = argparse.ArgumentParser(description="Email a campaign report to one or more recipients.")
parser.add_argument("--campaign-id", required=True, help="Database campaign UUID")
parser.add_argument("--to", action="append", required=True, help="Report recipient. Repeat for multiple recipients.")
parser.add_argument("--api-key", default=settings.dev_bootstrap_api_key)
parser.add_argument("--include-jobs", action="store_true", help="Include per-job rows in the JSON report payload before rendering")
parser.add_argument("--no-jobs-csv", action="store_true", help="Do not attach the per-job CSV report")
parser.add_argument("--attach-report-json", action="store_true", help="Attach the complete report JSON")
parser.add_argument("--dry-run", action="store_true", help="Build and validate the report email without SMTP sending")
parser.add_argument("--json", action="store_true", help="Print machine-readable JSON")
args = parser.parse_args()
create_all_tables()
with SessionLocal() as session:
api_key = authenticate_api_key(session, args.api_key)
if not api_key:
raise SystemExit("Invalid API key")
try:
result = send_campaign_report_email(
session,
tenant_id=api_key.tenant_id,
campaign_id=args.campaign_id,
to=args.to,
include_jobs=args.include_jobs,
attach_jobs_csv=not args.no_jobs_csv,
attach_report_json=args.attach_report_json,
dry_run=args.dry_run,
)
audit_event(
session,
tenant_id=api_key.tenant_id,
user_id=api_key.user_id,
api_key_id=api_key.id,
action="report.email_sent" if not args.dry_run else "report.email_dry_run",
object_type="campaign",
object_id=args.campaign_id,
details=result.as_dict(),
commit=True,
)
except (CampaignReportError, CampaignReportEmailError) as exc:
raise SystemExit(str(exc)) from exc
except Exception as exc:
raise SystemExit(f"Could not email campaign report: {exc}") from exc
if args.json:
print(json.dumps(result.as_dict(), indent=2, ensure_ascii=False))
else:
print(f"Campaign: {result.campaign_id}")
print(f"To: {', '.join(result.to)}")
print(f"Subject: {result.subject}")
print(f"Dry run: {result.dry_run}")
print(f"Sent: {result.sent}")
print(f"CSV: {result.attached_jobs_csv}")
print(f"JSON: {result.attached_report_json}")
if result.smtp_host:
print(f"SMTP: {result.smtp_host}:{result.smtp_port}")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,67 @@
from __future__ import annotations
import argparse
import json
from pathlib import Path
from app.audit.logging import audit_event
from app.db.bootstrap import create_all_tables
from app.db.session import SessionLocal
from app.db.models import User
from app.security.api_keys import authenticate_api_key
from app.settings import settings
from app.mailer.persistence.campaigns import create_campaign_version_from_json, validate_campaign_version, build_campaign_version
def main() -> None:
parser = argparse.ArgumentParser(description="Import a campaign JSON into the database and optionally validate/build it.")
parser.add_argument("--campaign", required=True, help="Path to campaign.json")
parser.add_argument("--api-key", default=settings.dev_bootstrap_api_key, help="API key used as the importing principal")
parser.add_argument("--validate", action="store_true", help="Run semantic validation after import")
parser.add_argument("--build", action="store_true", help="Build message jobs after import")
parser.add_argument("--no-eml", action="store_true", help="Do not write generated .eml files during build")
args = parser.parse_args()
create_all_tables()
campaign_path = Path(args.campaign).resolve()
raw_json = json.loads(campaign_path.read_text(encoding="utf-8"))
with SessionLocal() as session:
api_key = authenticate_api_key(session, args.api_key)
if not api_key:
raise SystemExit("Invalid API key. Run init_db --with-dev-data first or pass --api-key.")
user = session.get(User, api_key.user_id)
campaign, version = create_campaign_version_from_json(
session,
tenant_id=api_key.tenant_id,
user_id=user.id if user else None,
raw_json=raw_json,
source_filename=str(campaign_path),
)
audit_event(
session,
tenant_id=api_key.tenant_id,
user_id=api_key.user_id,
api_key_id=api_key.id,
action="campaign.imported",
object_type="campaign",
object_id=campaign.id,
details={"version_id": version.id, "source_filename": str(campaign_path)},
commit=True,
)
print(f"Campaign: {campaign.name} ({campaign.id})")
print(f"Version: {version.version_number} ({version.id})")
if args.validate:
report = validate_campaign_version(session, tenant_id=api_key.tenant_id, version_id=version.id)
audit_event(session, tenant_id=api_key.tenant_id, user_id=api_key.user_id, api_key_id=api_key.id, action="campaign.validated", object_type="campaign_version", object_id=version.id, details={"ok": report.get("ok")}, commit=True)
print(f"Validation: ok={report['ok']}, errors={report['error_count']}, warnings={report['warning_count']}")
if args.build:
report = build_campaign_version(session, tenant_id=api_key.tenant_id, version_id=version.id, write_eml=not args.no_eml)
audit_event(session, tenant_id=api_key.tenant_id, user_id=api_key.user_id, api_key_id=api_key.id, action="campaign.messages_built", object_type="campaign_version", object_id=version.id, details={"built_count": report.get("built_count"), "write_eml": not args.no_eml}, commit=True)
print(f"Build: built={report['built_count']}, queueable={report['queueable_count']}, needs_review={report['needs_review_count']}")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,32 @@
from __future__ import annotations
import argparse
from app.db.bootstrap import bootstrap_dev_data, create_all_tables
from app.db.session import SessionLocal
from app.settings import settings
def main() -> None:
parser = argparse.ArgumentParser(description="Initialize the MultiMailer database")
parser.add_argument("--with-dev-data", action="store_true", help="Create default tenant/user/roles and a development API key")
parser.add_argument("--dev-api-key", default=settings.dev_bootstrap_api_key, help="Development API key secret to create")
args = parser.parse_args()
create_all_tables()
print("Database tables ensured.")
if args.with_dev_data:
with SessionLocal() as session:
result = bootstrap_dev_data(session, api_key_secret=args.dev_api_key)
print(f"Tenant: {result.tenant.slug} ({result.tenant.id})")
print(f"User: {result.user.email} ({result.user.id})")
if result.created_api_key:
print("Development API key created:")
print(result.created_api_key.secret)
else:
print("Development API key already exists or was not requested.")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,29 @@
from __future__ import annotations
import argparse
from app.db.bootstrap import create_all_tables
from app.db.models import Campaign, CampaignJob
from app.db.session import SessionLocal
from app.security.api_keys import authenticate_api_key
from app.settings import settings
def main() -> None:
parser = argparse.ArgumentParser(description="List persisted campaigns and job counts.")
parser.add_argument("--api-key", default=settings.dev_bootstrap_api_key)
args = parser.parse_args()
create_all_tables()
with SessionLocal() as session:
api_key = authenticate_api_key(session, args.api_key)
if not api_key:
raise SystemExit("Invalid API key")
campaigns = session.query(Campaign).filter(Campaign.tenant_id == api_key.tenant_id).order_by(Campaign.updated_at.desc()).all()
for campaign in campaigns:
jobs = session.query(CampaignJob).filter(CampaignJob.campaign_id == campaign.id).count()
print(f"{campaign.id} | {campaign.status:15s} | jobs={jobs:4d} | {campaign.name}")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,54 @@
from __future__ import annotations
import argparse
import json
from app.db.bootstrap import create_all_tables
from app.db.models import Campaign
from app.db.session import SessionLocal
from app.mailer.sending.jobs import queue_campaign_jobs
from app.security.api_keys import authenticate_api_key
from app.settings import settings
def main() -> None:
parser = argparse.ArgumentParser(description="Queue built campaign jobs for sending.")
parser.add_argument("--campaign-id", required=True, help="Database campaign UUID, not external campaign id")
parser.add_argument("--version-id", default=None, help="Optional campaign version UUID; defaults to current version")
parser.add_argument("--api-key", default=settings.dev_bootstrap_api_key)
parser.add_argument("--no-celery", action="store_true", help="Only mark jobs as queued; do not enqueue Celery tasks")
parser.add_argument("--exclude-warnings", action="store_true", help="Queue only validation_status=ready, not warnings")
parser.add_argument("--dry-run", action="store_true")
parser.add_argument("--json", action="store_true")
args = parser.parse_args()
create_all_tables()
with SessionLocal() as session:
api_key = authenticate_api_key(session, args.api_key)
if not api_key:
raise SystemExit("Invalid API key")
result = queue_campaign_jobs(
session,
tenant_id=api_key.tenant_id,
campaign_id=args.campaign_id,
version_id=args.version_id,
enqueue_celery=not args.no_celery,
include_warnings=not args.exclude_warnings,
dry_run=args.dry_run,
)
if args.json:
print(json.dumps(result.as_dict(), indent=2))
return
campaign = session.get(Campaign, args.campaign_id)
print(f"Campaign: {campaign.name if campaign else args.campaign_id}")
print(f"Version: {result.version_id}")
print(f"Queued: {result.queued_count}")
print(f"Skipped: {result.skipped_count}")
print(f"Blocked: {result.blocked_count}")
print(f"Enqueued Celery tasks: {result.enqueued_count}")
if result.dry_run:
print("Dry run: no database changes were committed")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,69 @@
from __future__ import annotations
import argparse
import json
import sys
from pathlib import Path
from app.mailer.attachments.resolver import AttachmentResolutionReport, resolve_campaign_attachments
from app.mailer.campaign.loader import CampaignLoadError, load_campaign_config
from app.mailer.campaign.entries import EntryLoadError
def _print_report(report: AttachmentResolutionReport, *, verbose: bool = False) -> None:
print(f"Campaign: {report.campaign_name} ({report.campaign_id})")
print(f"Campaign file: {report.campaign_file}")
print(f"Attachments base path: {report.attachments_base_path}")
print(f"Entries: {report.entries_count}")
print(
"Status: "
f"ready={report.ready_count}, "
f"warning={report.warning_count}, "
f"needs_review={report.needs_review_count}, "
f"blocked={report.blocked_count}, "
f"excluded={report.excluded_count}, "
f"inactive={report.inactive_count}"
)
for entry in report.entries:
print("---")
label = entry.entry_id or f"#{entry.entry_index}"
print(f"Entry {label}: {entry.status.value}, matches={entry.match_count}")
for issue in entry.issues:
behavior = f", behavior={issue.behavior.value}" if issue.behavior else ""
print(f" [{issue.severity.value}] {issue.code}{behavior}: {issue.message}")
if verbose:
for attachment in entry.attachments:
print(
f" - {attachment.scope.value}[{attachment.index}] "
f"{attachment.attachment_id or ''} "
f"{attachment.status.value}: {attachment.directory}/{attachment.file_filter}"
)
for match in attachment.matches:
print(f" {match}")
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description="Resolve campaign attachment patterns and report missing/ambiguous matches.")
parser.add_argument("--campaign", required=True, help="Path to campaign.json")
parser.add_argument("--json", action="store_true", help="Output machine-readable JSON")
parser.add_argument("--verbose", "-v", action="store_true", help="Print resolved configs and matched files")
args = parser.parse_args(argv)
campaign_path = Path(args.campaign)
try:
config = load_campaign_config(campaign_path)
report = resolve_campaign_attachments(config, campaign_file=campaign_path)
except (CampaignLoadError, EntryLoadError, ValueError) as exc:
print(f"Error: {exc}", file=sys.stderr)
return 2
if args.json:
print(json.dumps(report.model_dump(mode="json"), ensure_ascii=False, indent=2))
else:
_print_report(report, verbose=args.verbose)
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,71 @@
from __future__ import annotations
import argparse
import json
from time import sleep
from app.db.bootstrap import create_all_tables
from app.db.models import CampaignJob, JobQueueStatus, JobSendStatus
from app.db.session import SessionLocal
from app.mailer.sending.jobs import append_sent_for_job, send_campaign_job
from app.security.api_keys import authenticate_api_key
from app.settings import settings
def main() -> None:
parser = argparse.ArgumentParser(description="Process queued campaign jobs directly, without a Celery worker.")
parser.add_argument("--campaign-id", required=True)
parser.add_argument("--api-key", default=settings.dev_bootstrap_api_key)
parser.add_argument("--limit", type=int, default=0, help="Maximum jobs to process; 0 means all queued jobs")
parser.add_argument("--dry-run", action="store_true", help="Validate/send path without SMTP delivery or status mutation to SENT")
parser.add_argument("--no-rate-limit", action="store_true")
parser.add_argument("--append-sent", action="store_true", help="After successful SMTP delivery, immediately run IMAP append-to-Sent in this CLI process")
parser.add_argument("--json", action="store_true")
args = parser.parse_args()
create_all_tables()
results = []
with SessionLocal() as session:
api_key = authenticate_api_key(session, args.api_key)
if not api_key:
raise SystemExit("Invalid API key")
query = (
session.query(CampaignJob)
.filter(
CampaignJob.tenant_id == api_key.tenant_id,
CampaignJob.campaign_id == args.campaign_id,
CampaignJob.queue_status == JobQueueStatus.QUEUED.value,
CampaignJob.send_status.in_([JobSendStatus.QUEUED.value, JobSendStatus.FAILED_TEMPORARY.value]),
)
.order_by(CampaignJob.entry_index.asc())
)
if args.limit > 0:
query = query.limit(args.limit)
jobs = query.all()
for job in jobs:
try:
result = send_campaign_job(session, job_id=job.id, dry_run=args.dry_run, use_rate_limit=not args.no_rate_limit)
result_dict = result.as_dict()
if args.append_sent and result.status == "sent":
append_result = append_sent_for_job(session, job_id=job.id, dry_run=args.dry_run)
result_dict["imap_append"] = append_result.as_dict()
results.append(result_dict)
if not args.json:
line = f"{job.entry_index}: {result.status} ({job.recipient_email})"
if "imap_append" in result_dict:
line += f"; IMAP: {result_dict['imap_append']['status']}"
print(line)
except Exception as exc:
results.append({"job_id": job.id, "status": "error", "error": str(exc)})
if not args.json:
print(f"{job.entry_index}: ERROR {exc} ({job.recipient_email})")
# Continue with the next job; individual attempts/statuses are recorded.
sleep(0.1)
if args.json:
print(json.dumps({"processed": len(results), "results": results}, indent=2))
elif not jobs:
print("No queued jobs found")
if __name__ == "__main__":
main()

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())

View File

@@ -0,0 +1,71 @@
from __future__ import annotations
import argparse
import json
import sys
from pathlib import Path
from app.mailer.campaign.loader import CampaignLoadError, CampaignSchemaError, load_campaign_config
from app.mailer.campaign.validation import Severity, validate_campaign_config
def _default_campaign_path() -> Path:
return Path(__file__).resolve().parents[1] / "examples" / "campaign.json"
def _print_text_report(report) -> None:
print(f"Campaign: {report.campaign_name} ({report.campaign_id})")
print(f"Entries: {report.entries_mode}" + (f", {report.entries_count} item(s)" if report.entries_count is not None else ""))
print(f"Attachments base path: {report.attachments_base_path}")
print(f"Rate limit: {report.rate_limit}")
print(f"IMAP append: {'enabled' if report.imap_append_enabled else 'disabled'}")
print(f"Issues: {report.error_count} error(s), {report.warning_count} warning(s)")
if report.issues:
print()
for issue in report.issues:
location = f" [{issue.path}]" if issue.path else ""
print(f"- {issue.severity.upper()} {issue.code}{location}: {issue.message}")
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description="Validate a MultiMailer campaign JSON file.")
parser.add_argument("--campaign", default=str(_default_campaign_path()), help="Path to campaign JSON file")
parser.add_argument("--schema", default=None, help="Optional path to campaign.schema.json")
parser.add_argument("--no-schema", action="store_true", help="Skip JSON Schema validation")
parser.add_argument("--check-files", action="store_true", help="Check referenced local files and CSV headers")
parser.add_argument("--json", action="store_true", help="Print machine-readable validation report")
args = parser.parse_args(argv)
campaign_path = Path(args.campaign).resolve()
try:
config = load_campaign_config(
campaign_path,
validate_schema=not args.no_schema,
schema_path=args.schema,
)
report = validate_campaign_config(config, campaign_file=campaign_path, check_files=args.check_files)
except CampaignSchemaError as exc:
if args.json:
print(json.dumps({"ok": False, "schema_errors": [error.__dict__ for error in exc.errors]}, indent=2), file=sys.stdout)
else:
print(str(exc), file=sys.stderr)
for error in exc.errors:
print(f"- {error.path}: {error.message}", file=sys.stderr)
return 2
except CampaignLoadError as exc:
print(str(exc), file=sys.stderr)
return 2
except Exception as exc:
print(f"campaign validation failed: {exc}", file=sys.stderr)
return 2
if args.json:
print(report.model_dump_json(indent=2))
else:
_print_text_report(report)
return 0 if report.ok else 1
if __name__ == "__main__":
raise SystemExit(main())