From b67c8abdc5ac69180db2a712eecdc6d3a0abd281 Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Thu, 11 Jun 2026 02:54:39 +0200 Subject: [PATCH] support versions, campaign structure change --- .gitignore | 2 + server/app/api/v1/campaigns.py | 178 +++++++++++++++++++-- server/app/api/v1/schemas.py | 15 +- server/app/mailer/persistence/campaigns.py | 30 +++- server/app/mailer/persistence/versions.py | 116 ++++++++++++-- server/app/mailer/sending/jobs.py | 17 ++ server/multimailer-dev.db | Bin 647168 -> 679936 bytes 7 files changed, 318 insertions(+), 40 deletions(-) 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 48d973c30fb8d2da56fd9aa31b0c5c752f5e0e97..39a31f6f458467a27c42e8d7f2fc24d20949f767 100644 GIT binary patch delta 15674 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 delta 2031 zcmZ`)Yfx3!6+ZiM&ffdH)&()75#ds=ib;eE2vMv;n`)|Ud?Y55s_1Pb!K7BNN?SB$ zxRD^XjS;kQD`{}VYSc+XDC*XvGZL#4$FcR%I8sYS;v*Sqzy>2YVoLV~r_=t(pEK)x z-&$+0wa)tXiM5Q0wT!NLz)B7phEZP|oQ#u$uS5<+(EWvwon4UqNI_0+X7;#nPHtFM z7or#c2h5!?ENFlaM^shepVg^~ACAbMo?X@((Q4WBBF{iY2ZMx$4^aFy0NgSt9K&!7I^YvHt3&pKJhqG!ITX$ta2?J=52*Ao zJHncSLB{3z3X(;%2+(9LTmtlBvPJ zfV^;o|RI~9ir2;f&8qH z>Pf`9K;E876LsuepuM`B0YBBdjC$``$geW|D((N0uc|W53e-%CT0ylHni+usGhS9` zOT?ruhMxGuwm={B=oB3=WEIix<;d1g3dIJrLZb>q%|3r~F!;1At}x%WDE!!f3(Aj~ zx8mqrI)*+aWCZZ2>dK9#s@!P3QWOe!%MGv2zwG_l`@)|LMe(2aQws2>X-U8 zuuhja&6ACVrm24MiS;@r0cVF~?bdMWj`ce-^d)0}GyL>%M+L;h!GO3hZ@v;l@@(adI$jr?fAD$pmoICyzwSF;M)tj!z`$N*L$4gU4H@pk4!dwW0<+sr~8l_YHKgwu?7nW=i zFNh+M%)9t;{yV;sPv`0EHapFBvzOT%_7KC?->vtoYHNX&XSwDT^L?|{Tx=Ga_t8#z zjQo}CB5TN#UP7 zE-oL-#)YOSC%qv?s99@x6B&z68IPfmfy&{+zyRk#HgNwEYVZ&G8}M|$#GmLVdL3Si zC%vD^oEQ&1W4Gd<-Dt0~=i5295SPS3u|brGi6W7A@D?(aOTLs(;VJAUJBenn9qgBE z1{-Php&sj;wHJkOnH96fTGaf9*@U~zYIA{^hsupJYF~_h8&TYCJh(X_IBKxJ$sdqXifIU0s1Kb(yYVU8CqZ$QbdwY2{bq}~ z#av>2$2o5eao%y(iuavooGiz*+w5lh*Y;2B@#tkV2R%fO4a}g*{2t7QT-*S*|EYh- z-)R2Re_k)sBpUUT$aL?zcT}%em$wb~cv0^$FV($;L)LTjyD9D|cc;6W)Vs6Y45uHL zlQKL5A)X<&T0_tov5W~ZRis+&=nm^QF0dSP3BScpks#X5cbYAHweD`xKzGWl<-aj8 zd04lXp%!Y{b@(MbA5X%m zs2{bX4^acEL?vhn8ikm@#DA2px2{`9t!=PDryfRB^2fZ>K?|hW&YKd&67i^bfPcwP z@a=r97MCF-H+Y&H`6*9Ox7+zN2a~wshZFguPM)SJZt$w2Kr!kv;4AnXE}|~Jm%k}< z!s6XjUd|Ws8GHf{abS1YHFk!5paoaU*056cglZ3qRD`YVI@_C;?8+9N+B06vA59jq z2FW7gBb68vHCX>JzE9<*=fqH@O2y+DG?SMZDqJJDsi~5CS{u;7TU$jbquH#-keZ)w zX_W_=zK(;hvG`2KsI)!e+BZ5h#T^c5hr}$tRNu?+2Ys2$gB%ckyG&^jp(N+5v&X4$ uo^~E~h<(v+vVUbqRHQ}x*3y-{h6aSCR-O>G%tG8XxT}xGT)C~{EA}6bcwqzp