inital commit
This commit is contained in:
1
server/app/mailer/commands/__init__.py
Normal file
1
server/app/mailer/commands/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""CLI commands for MultiMailer development workflows."""
|
||||
69
server/app/mailer/commands/append_pending_sent.py
Normal file
69
server/app/mailer/commands/append_pending_sent.py
Normal 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()
|
||||
65
server/app/mailer/commands/audit_log.py
Normal file
65
server/app/mailer/commands/audit_log.py
Normal 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()
|
||||
99
server/app/mailer/commands/build_messages.py
Normal file
99
server/app/mailer/commands/build_messages.py
Normal 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())
|
||||
78
server/app/mailer/commands/campaign_report.py
Normal file
78
server/app/mailer/commands/campaign_report.py
Normal 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()
|
||||
74
server/app/mailer/commands/email_campaign_report.py
Normal file
74
server/app/mailer/commands/email_campaign_report.py
Normal 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()
|
||||
67
server/app/mailer/commands/import_campaign.py
Normal file
67
server/app/mailer/commands/import_campaign.py
Normal 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()
|
||||
32
server/app/mailer/commands/init_db.py
Normal file
32
server/app/mailer/commands/init_db.py
Normal 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()
|
||||
29
server/app/mailer/commands/list_db_campaigns.py
Normal file
29
server/app/mailer/commands/list_db_campaigns.py
Normal 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()
|
||||
54
server/app/mailer/commands/queue_campaign.py
Normal file
54
server/app/mailer/commands/queue_campaign.py
Normal 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()
|
||||
69
server/app/mailer/commands/resolve_attachments.py
Normal file
69
server/app/mailer/commands/resolve_attachments.py
Normal 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())
|
||||
71
server/app/mailer/commands/send_queued_jobs.py
Normal file
71
server/app/mailer/commands/send_queued_jobs.py
Normal 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()
|
||||
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())
|
||||
71
server/app/mailer/commands/validate_campaign.py
Normal file
71
server/app/mailer/commands/validate_campaign.py
Normal 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())
|
||||
Reference in New Issue
Block a user