    "message_type": MSG_TYPES,
    "attach_mode": {"none", "pdf", "file", "image"},
    "rotation_strategy": {"sequential", "weighted"},
    "subject_rotation": {"round_robin", "random", "fixed_first"},
    "template_sequence_mode": {"repeat", "stop", "random"},
    "phone_selection": {"round_robin", "random", "fixed"},
}


def _ser(c: Campaign) -> dict:
    return {
        "id": c.id, "name": c.name, "status": c.status,
        "recipient_list_id": c.recipient_list_id,
        "subject_set_ids": c.subject_set_ids or [],
        "template_ids": c.template_ids or [],
        "message_type": c.message_type, "attach_mode": c.attach_mode,
        "attach_pdf": bool(c.attach_pdf),
        "attach_pdf_template_id": c.attach_pdf_template_id,
        "attachment_id": c.attachment_id,
        "variable_config": c.variable_config or {},
        "account_ids": c.account_ids or [],
        "rotation_strategy": c.rotation_strategy,
        "rotation_weights": c.rotation_weights or {},
        "subject_rotation": c.subject_rotation,
        "template_sequence_mode": c.template_sequence_mode,
        "phone_selection": c.phone_selection,
        "max_per_account": c.max_per_account,
        "send_interval_seconds": c.send_interval_seconds,
        "rate_per_minute": c.rate_per_minute,
        "max_retries": c.max_retries,
        "retry_delay_seconds": c.retry_delay_seconds,
        "unsubscribe_enabled": bool(c.unsubscribe_enabled),
        "preview_sample_size": c.preview_sample_size,
        "total_recipients": c.total_recipients, "sent_count": c.sent_count,
        "failed_count": c.failed_count, "suppressed_count": c.suppressed_count,
        "skipped_count": c.skipped_count, "queued_count": c.queued_count,
        "started_at": c.started_at.isoformat() if c.started_at else None,
        "finished_at": c.finished_at.isoformat() if c.finished_at else None,
        "created_at": c.created_at.isoformat() if c.created_at else None,
    }


async def _get_own(db: AsyncSession, user: User, cid: int) -> Campaign:
    c = await db.get(Campaign, cid)
    if not c or c.user_id != user.id:
        raise HTTPException(status_code=404, detail="Campaign not found")
    return c


async def _validate_refs(db: AsyncSession, user: User, data: CampaignIn):
    """Wizard-level validation: all referenced entities must exist & belong to the user."""
    errors = []
    if data.message_type not in MSG_TYPES:
        errors.append("message_type must be html|plain|both")
    if data.attach_mode not in VALID_ENUMS["attach_mode"]:
        errors.append("attach_mode must be none|pdf|file|image")
    if data.rotation_strategy not in VALID_ENUMS["rotation_strategy"]:
        errors.append("rotation_strategy must be sequential|weighted")
    if data.subject_rotation not in VALID_ENUMS["subject_rotation"]:
        errors.append("subject_rotation must be round_robin|random|fixed_first")
    if data.template_sequence_mode not in VALID_ENUMS["template_sequence_mode"]:
        errors.append("template_sequence_mode must be repeat|stop|random")
    if data.phone_selection not in VALID_ENUMS["phone_selection"]:
        errors.append("phone_selection must be round_robin|random|fixed")

    if data.recipient_list_id:
