diff --git a/.gitignore b/.gitignore index 77a7a53..386dd89 100644 --- a/.gitignore +++ b/.gitignore @@ -181,3 +181,5 @@ runtime runtime/ server/*.db +multi-seal-mail*.tar.gz +multisealmail*.zip diff --git a/server/app/api/v1/campaigns.py b/server/app/api/v1/campaigns.py index d731517..b258208 100644 --- a/server/app/api/v1/campaigns.py +++ b/server/app/api/v1/campaigns.py @@ -6,6 +6,7 @@ from sqlalchemy.orm import Session from app.api.v1.schemas import ( BuildCampaignRequest, CampaignCreateRequest, + CampaignUpdateRequest, CampaignCreateResponse, CampaignCreateMinimalRequest, CampaignJobsResponse, @@ -34,9 +35,14 @@ from app.mailer.persistence.campaigns import ( validate_campaign_version, ) from app.mailer.persistence.versions import ( + LockedCampaignVersionError, create_minimal_campaign, + fork_campaign_version_for_edit, + is_version_final_locked, + is_user_locked_version, get_campaign_version_for_tenant, publish_campaign_version, + unlock_validated_campaign_version, update_campaign_version, validate_campaign_partial, ) @@ -152,6 +158,50 @@ def get_campaign( return CampaignResponse.model_validate(_get_campaign_for_tenant(session, campaign_id, principal.tenant_id)) +@router.put("/{campaign_id}", response_model=CampaignResponse) +def update_campaign_metadata_endpoint( + campaign_id: str, + payload: CampaignUpdateRequest, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_scope("campaign:write")), +): + campaign = _get_campaign_for_tenant(session, campaign_id, principal.tenant_id) + if payload.external_id is not None: + value = payload.external_id.strip() + if not value: + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Campaign ID cannot be empty") + duplicate = ( + session.query(Campaign) + .filter(Campaign.tenant_id == principal.tenant_id, Campaign.external_id == value, Campaign.id != campaign.id) + .one_or_none() + ) + if duplicate: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Campaign ID already exists for this tenant") + campaign.external_id = value + if payload.name is not None: + value = payload.name.strip() + if not value: + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Campaign name cannot be empty") + campaign.name = value + if payload.status is not None: + campaign.status = payload.status + if payload.description is not None: + campaign.description = payload.description + session.add(campaign) + session.commit() + session.refresh(campaign) + audit_from_principal( + session, + principal, + action="campaign.metadata_updated", + object_type="campaign", + object_id=campaign.id, + details={"external_id": campaign.external_id, "name": campaign.name}, + commit=True, + ) + return CampaignResponse.model_validate(campaign) + + @router.get("/{campaign_id}/versions", response_model=list[CampaignVersionResponse]) def list_versions( campaign_id: str, @@ -184,6 +234,90 @@ def get_version_detail( raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc +@router.post("/{campaign_id}/versions/{version_id}/fork", response_model=CampaignCreateResponse) +def fork_version_for_edit( + campaign_id: str, + version_id: str, + payload: CampaignVersionUpdateRequest | None = None, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_scope("campaign:write")), +): + """Create a new editable campaign version from a locked/validated/sent version. + + Versions that were validated, built, queued or sent are immutable audit + snapshots. This endpoint makes an explicit editable copy and makes that + new copy the campaign's current version. + """ + + payload = payload or CampaignVersionUpdateRequest() + try: + version = fork_campaign_version_for_edit( + session, + tenant_id=principal.tenant_id, + campaign_id=campaign_id, + version_id=version_id, + raw_json=payload.campaign_json, + current_flow=payload.current_flow or "manual", + current_step=payload.current_step, + editor_state=payload.editor_state, + source_filename=payload.source_filename, + source_base_path=payload.source_base_path, + autosave=True, + ) + campaign = _get_campaign_for_tenant(session, campaign_id, principal.tenant_id) + audit_from_principal( + session, + principal, + action="campaign.version_forked_for_edit", + object_type="campaign_version", + object_id=version.id, + details={"campaign_id": campaign_id, "source_version_id": version_id, "version_number": version.version_number}, + commit=True, + ) + return CampaignCreateResponse( + campaign=CampaignResponse.model_validate(campaign), + version=CampaignVersionResponse.model_validate(version), + ) + except CampaignPersistenceError as exc: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc + + +@router.post("/{campaign_id}/versions/{version_id}/unlock-validation", response_model=CampaignVersionDetailResponse) +def unlock_version_validation( + campaign_id: str, + version_id: str, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_scope("campaign:write")), +): + """Unlock a successfully validated version before delivery starts. + + Unlocking invalidates validation/build state and removes generated jobs for + that version. Sent/final versions cannot be unlocked and must be copied. + """ + + try: + version = unlock_validated_campaign_version( + session, + tenant_id=principal.tenant_id, + campaign_id=campaign_id, + version_id=version_id, + ) + audit_from_principal( + session, + principal, + action="campaign.version_validation_unlocked", + object_type="campaign_version", + object_id=version.id, + details={"campaign_id": campaign_id}, + commit=True, + ) + return CampaignVersionDetailResponse.model_validate(version) + except LockedCampaignVersionError as exc: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc + except CampaignPersistenceError as exc: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc + + @router.put("/{campaign_id}/versions/{version_id}", response_model=CampaignVersionDetailResponse) def update_version_detail( campaign_id: str, @@ -218,6 +352,8 @@ def update_version_detail( commit=True, ) return CampaignVersionDetailResponse.model_validate(version) + except LockedCampaignVersionError as exc: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc except CampaignPersistenceError as exc: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc except Exception as exc: @@ -258,6 +394,8 @@ def autosave_version( commit=True, ) return CampaignVersionDetailResponse.model_validate(version) + except LockedCampaignVersionError as exc: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc except CampaignPersistenceError as exc: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc except Exception as exc: @@ -283,6 +421,8 @@ def set_version_step( autosave=True, ) return CampaignVersionDetailResponse.model_validate(version) + except LockedCampaignVersionError as exc: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc except CampaignPersistenceError as exc: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc @@ -346,6 +486,12 @@ def validate_version( principal: ApiPrincipal = Depends(require_scope("campaign:validate")), ): try: + version = _get_version_for_tenant(session, version_id, principal.tenant_id) + if is_user_locked_version(version) or is_version_final_locked(version): + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="This version is audit-safe/final and cannot be validated again. Create an editable copy instead.", + ) result = validate_campaign_version( session, tenant_id=principal.tenant_id, @@ -608,25 +754,23 @@ def send_campaign_now_endpoint( if not version_id: raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Campaign has no current version") - validation_result: dict[str, object] | None = None + version = _get_version_for_tenant(session, version_id, principal.tenant_id) + validation_result: dict[str, object] | None = version.validation_summary if isinstance(version.validation_summary, dict) else None build_result: dict[str, object] | None = None - if payload.validate_before_send: - validation_result = validate_campaign_version( - session, - tenant_id=principal.tenant_id, - version_id=version_id, - check_files=payload.check_files, - user_id=principal.user.id, + if is_user_locked_version(version): + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="User-locked audit-safe versions cannot be dry-run or sent. Create an editable copy and validate it instead.", ) - if not validation_result.get("ok"): - raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail={"message": "Campaign validation failed", "validation": validation_result}) - - if payload.build_before_send: - build_result = build_campaign_version( - session, - tenant_id=principal.tenant_id, - version_id=version_id, - write_eml=True, + if not version.locked_at or not validation_result or validation_result.get("ok") is not True: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="Campaign version must be validated and locked before dry-run or sending.", + ) + if not version.build_summary: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="Campaign version must be built before dry-run or sending.", ) result = send_campaign_now( diff --git a/server/app/api/v1/schemas.py b/server/app/api/v1/schemas.py index cf07a63..b7cc698 100644 --- a/server/app/api/v1/schemas.py +++ b/server/app/api/v1/schemas.py @@ -16,6 +16,17 @@ class CampaignCreateRequest(BaseModel): source_base_path: str | None = None + + +class CampaignUpdateRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + external_id: str | None = None + name: str | None = None + status: str | None = None + description: str | None = None + + class CampaignCreateMinimalRequest(BaseModel): model_config = ConfigDict(extra="forbid") @@ -205,8 +216,8 @@ class SendCampaignNowRequest(BaseModel): version_id: str | None = None include_warnings: bool = True check_files: bool = False - validate_before_send: bool = True - build_before_send: bool = True + validate_before_send: bool = False + build_before_send: bool = False dry_run: bool = False use_rate_limit: bool = True enqueue_imap_task: bool = False diff --git a/server/app/mailer/persistence/campaigns.py b/server/app/mailer/persistence/campaigns.py index 4d3e277..37dcb47 100644 --- a/server/app/mailer/persistence/campaigns.py +++ b/server/app/mailer/persistence/campaigns.py @@ -147,6 +147,23 @@ def create_campaign_version_from_json( return campaign, version + + +def _version_is_user_locked(version: CampaignVersion) -> bool: + return bool(version.published_at) + + +def _version_is_validated_and_locked(version: CampaignVersion) -> bool: + validation_summary = version.validation_summary if isinstance(version.validation_summary, dict) else {} + return bool(version.locked_at and validation_summary.get("ok") is True and not _version_is_user_locked(version)) + + +def _ensure_version_validated_and_locked(version: CampaignVersion) -> None: + if _version_is_user_locked(version): + raise CampaignPersistenceError("User-locked audit-safe versions cannot be built, queued, dry-run or sent. Create an editable copy instead.") + if not _version_is_validated_and_locked(version): + raise CampaignPersistenceError("Campaign version must be validated and locked before building, queueing, dry-run or sending.") + def load_version_config(session: Session, version_id: str): version = session.get(CampaignVersion, version_id) if not version: @@ -168,6 +185,14 @@ def validate_campaign_version( campaign = session.get(Campaign, version.campaign_id) if not campaign or campaign.tenant_id != tenant_id: raise CampaignPersistenceError("Campaign version is not accessible for this tenant") + if version.published_at or version.workflow_state in { + CampaignVersionWorkflowState.QUEUED.value, + CampaignVersionWorkflowState.SENDING.value, + CampaignVersionWorkflowState.COMPLETED.value, + CampaignVersionWorkflowState.CANCELLED.value, + CampaignVersionWorkflowState.ARCHIVED.value, + }: + raise CampaignPersistenceError("Audit-safe/final campaign versions cannot be validated. Create an editable copy instead.") report = validate_campaign_config(config, campaign_file=snapshot_path, check_files=check_files) report_json = report.model_dump(mode="json") @@ -269,10 +294,7 @@ def build_campaign_version( validation_summary = version.validation_summary if isinstance(version.validation_summary, dict) else {} if not validation_summary.get("ok"): raise CampaignPersistenceError("Campaign version must be successfully validated before messages are built") - if version.locked_at is None: - from datetime import UTC, datetime - - version.locked_at = datetime.now(UTC) + _ensure_version_validated_and_locked(version) output_dir = BUILD_OUTPUT_DIR / campaign.id / version.id result = build_campaign_messages(config, campaign_file=snapshot_path, output_dir=output_dir, write_eml=write_eml) diff --git a/server/app/mailer/persistence/versions.py b/server/app/mailer/persistence/versions.py index 043dcfb..b464fc9 100644 --- a/server/app/mailer/persistence/versions.py +++ b/server/app/mailer/persistence/versions.py @@ -15,6 +15,8 @@ from app.db.models import ( CampaignVersion, CampaignVersionFlow, CampaignVersionWorkflowState, + CampaignJob, + JobSendStatus, ) from app.mailer.campaign.loader import load_campaign_config from app.mailer.persistence.campaigns import ( @@ -27,6 +29,11 @@ from app.mailer.persistence.campaigns import ( ) +class LockedCampaignVersionError(CampaignPersistenceError): + """Raised when a caller tries to edit an immutable campaign version.""" + + + def minimal_campaign_json(*, external_id: str, name: str, description: str | None = None) -> dict[str, Any]: """Return a WebUI-friendly starter campaign JSON. @@ -290,6 +297,90 @@ def lock_validated_version(version: CampaignVersion, *, user_id: str | None = No version.locked_at = datetime.now(UTC) version.locked_by_user_id = user_id + +def is_version_final_locked(version: CampaignVersion) -> bool: + """Return True when a version is part of or past delivery and must stay immutable.""" + + return version.workflow_state in { + CampaignVersionWorkflowState.QUEUED.value, + CampaignVersionWorkflowState.SENDING.value, + CampaignVersionWorkflowState.COMPLETED.value, + CampaignVersionWorkflowState.CANCELLED.value, + CampaignVersionWorkflowState.ARCHIVED.value, + } + + +def is_user_locked_version(version: CampaignVersion) -> bool: + """Return True when a user explicitly locked a version as an audit-safe snapshot.""" + + return bool(version.published_at) + + +def is_audit_safe_version(version: CampaignVersion) -> bool: + """Return True when a version is immutable and cannot be unlocked.""" + + return is_user_locked_version(version) or is_version_final_locked(version) + + +def is_version_validated_and_locked(version: CampaignVersion) -> bool: + """Return True when the version was successfully validated and locked as a review snapshot.""" + + validation = version.validation_summary if isinstance(version.validation_summary, dict) else {} + return bool(version.locked_at and validation.get("ok") is True) + + +def unlock_validated_campaign_version( + session: Session, + *, + tenant_id: str, + campaign_id: str, + version_id: str, +) -> CampaignVersion: + """Unlock a validation snapshot so it can be edited again. + + This is only allowed before delivery starts. Unlocking invalidates validation, + build output and queued job records for that version. Sent/final versions must + be copied instead. + """ + + version = get_campaign_version_for_tenant(session, tenant_id=tenant_id, campaign_id=campaign_id, version_id=version_id) + campaign = session.get(Campaign, campaign_id) + assert campaign is not None + + if is_user_locked_version(version): + raise LockedCampaignVersionError("This version was locked as an audit-safe snapshot and cannot be unlocked. Create an editable copy instead.") + if is_version_final_locked(version): + raise LockedCampaignVersionError("This version is already queued/sent/final and cannot be unlocked. Create an editable copy instead.") + + # A version with sent jobs is final even if workflow_state was not updated for some reason. + sent_jobs = ( + session.query(CampaignJob) + .filter( + CampaignJob.campaign_version_id == version.id, + CampaignJob.send_status == JobSendStatus.SENT.value, + ) + .count() + ) + if sent_jobs: + raise LockedCampaignVersionError("This version has sent messages and cannot be unlocked. Create an editable copy instead.") + + version.locked_at = None + version.locked_by_user_id = None + version.validation_summary = None + version.build_summary = None + version.workflow_state = CampaignVersionWorkflowState.EDITING.value + version.is_complete = False + + session.query(CampaignIssue).filter(CampaignIssue.campaign_version_id == version.id).delete(synchronize_session=False) + session.query(CampaignJob).filter(CampaignJob.campaign_version_id == version.id).delete(synchronize_session=False) + + campaign.current_version_id = version.id + campaign.status = CampaignStatus.DRAFT.value + session.add(version) + session.add(campaign) + session.commit() + return version + def update_campaign_version( session: Session, *, @@ -311,20 +402,8 @@ def update_campaign_version( assert campaign is not None if is_version_locked(version): - if raw_json is None: - raise CampaignPersistenceError("Campaign version is locked. Save campaign changes to create a new editable version.") - return fork_campaign_version_for_edit( - session, - tenant_id=tenant_id, - campaign_id=campaign_id, - version_id=version_id, - raw_json=raw_json, - current_flow=current_flow, - current_step=current_step, - editor_state=editor_state, - source_filename=source_filename, - source_base_path=source_base_path, - autosave=autosave, + raise LockedCampaignVersionError( + "Campaign version is locked. Create an editable copy before changing campaign data." ) if raw_json is not None: @@ -379,10 +458,13 @@ def publish_campaign_version( version = get_campaign_version_for_tenant(session, tenant_id=tenant_id, campaign_id=campaign_id, version_id=version_id) campaign = session.get(Campaign, campaign_id) assert campaign is not None - version.workflow_state = CampaignVersionWorkflowState.APPROVED.value - version.published_at = datetime.now(UTC) + now = datetime.now(UTC) + version.workflow_state = CampaignVersionWorkflowState.ARCHIVED.value + version.published_at = now + if version.locked_at is None: + version.locked_at = now campaign.current_version_id = version.id - campaign.status = CampaignStatus.VALIDATED.value + campaign.status = CampaignStatus.ARCHIVED.value session.add(version) session.add(campaign) session.commit() diff --git a/server/app/mailer/sending/jobs.py b/server/app/mailer/sending/jobs.py index 9840327..e3ac4cd 100644 --- a/server/app/mailer/sending/jobs.py +++ b/server/app/mailer/sending/jobs.py @@ -130,6 +130,22 @@ QUEUEABLE_VALIDATION_STATUSES = { } +def _version_is_user_locked(version: CampaignVersion) -> bool: + return bool(version.published_at) + + +def _version_is_validated_and_locked(version: CampaignVersion) -> bool: + validation = version.validation_summary if isinstance(version.validation_summary, dict) else {} + return bool(version.locked_at and validation.get("ok") is True and not _version_is_user_locked(version)) + + +def _ensure_version_validated_and_locked(version: CampaignVersion) -> None: + if _version_is_user_locked(version): + raise QueueingError("User-locked audit-safe versions cannot be queued, dry-run or sent. Create an editable copy instead.") + if not _version_is_validated_and_locked(version): + raise QueueingError("Campaign version must be validated and locked before building, queueing, dry-run or sending.") + + def _utcnow() -> datetime: return datetime.now(timezone.utc) @@ -182,6 +198,7 @@ def queue_campaign_jobs( campaign = _get_campaign_for_tenant(session, campaign_id=campaign_id, tenant_id=tenant_id) version = _get_current_version(session, campaign, version_id=version_id) + _ensure_version_validated_and_locked(version) allowed_validation = {JobValidationStatus.READY.value} if include_warnings: diff --git a/server/multimailer-dev.db b/server/multimailer-dev.db index 48d973c..39a31f6 100644 Binary files a/server/multimailer-dev.db and b/server/multimailer-dev.db differ