From f3db5fc5cf01b1ac2247fd0541f2544ff1ef410d Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Fri, 12 Jun 2026 02:18:30 +0200 Subject: [PATCH] mock server, file and folder management --- .env.example | 10 + .../3d4e5f6a7b8c_file_storage_backend.py | 152 ++++ .../versions/4e5f6a7b8c9d_file_folders.py | 45 ++ server/app/api/v1/__init__.py | 4 + server/app/api/v1/campaigns.py | 221 +++++- server/app/api/v1/dev_mail.py | 95 +++ server/app/api/v1/files.py | 641 +++++++++++++++ server/app/api/v1/schemas.py | 16 + server/app/db/models.py | 101 +++ server/app/mailer/attachments/resolver.py | 5 + server/app/mailer/campaign/models.py | 15 + server/app/mailer/dev/__init__.py | 1 + server/app/mailer/dev/mock_campaign.py | 280 +++++++ server/app/mailer/dev/mock_mailbox.py | 327 ++++++++ server/app/mailer/messages/builder.py | 65 ++ server/app/mailer/persistence/campaigns.py | 2 + server/app/mailer/persistence/versions.py | 2 + server/app/mailer/schema/campaign.schema.json | 27 +- server/app/mailer/sending/imap.py | 38 + server/app/mailer/sending/jobs.py | 3 + server/app/mailer/sending/smtp.py | 46 ++ server/app/main.py | 8 +- server/app/settings.py | 11 + server/app/storage/__init__.py | 1 + server/app/storage/backends.py | 116 +++ server/app/storage/paths.py | 73 ++ server/app/storage/services.py | 750 ++++++++++++++++++ server/multimailer-dev.db | Bin 679936 -> 1011712 bytes 28 files changed, 3049 insertions(+), 6 deletions(-) create mode 100644 server/alembic/versions/3d4e5f6a7b8c_file_storage_backend.py create mode 100644 server/alembic/versions/4e5f6a7b8c9d_file_folders.py create mode 100644 server/app/api/v1/dev_mail.py create mode 100644 server/app/api/v1/files.py create mode 100644 server/app/mailer/dev/__init__.py create mode 100644 server/app/mailer/dev/mock_campaign.py create mode 100644 server/app/mailer/dev/mock_mailbox.py create mode 100644 server/app/storage/__init__.py create mode 100644 server/app/storage/backends.py create mode 100644 server/app/storage/paths.py create mode 100644 server/app/storage/services.py diff --git a/.env.example b/.env.example index dbd768b..b265eed 100644 --- a/.env.example +++ b/.env.example @@ -33,6 +33,16 @@ S3_SECRET_ACCESS_KEY=multimailer-dev-secret-change-me GARAGE_S3_PORT=3900 GARAGE_ADMIN_PORT=3903 +# Managed file storage. Development uses the local filesystem; production can +# use FILE_STORAGE_BACKEND=s3 with Garage-compatible credentials. +FILE_STORAGE_BACKEND=local +FILE_STORAGE_LOCAL_ROOT=runtime/files +FILE_STORAGE_S3_ENDPOINT_URL=http://garage:3900 +FILE_STORAGE_S3_REGION=garage +FILE_STORAGE_S3_BUCKET=files +FILE_STORAGE_S3_ACCESS_KEY_ID=GKmultimailerdev0000000000000000 +FILE_STORAGE_S3_SECRET_ACCESS_KEY=multimailer-dev-secret-change-me + # Crypto: required before storing real SMTP/IMAP credentials. # Generate: # python -c "import os,base64; print(base64.b64encode(os.urandom(32)).decode())" diff --git a/server/alembic/versions/3d4e5f6a7b8c_file_storage_backend.py b/server/alembic/versions/3d4e5f6a7b8c_file_storage_backend.py new file mode 100644 index 0000000..862da74 --- /dev/null +++ b/server/alembic/versions/3d4e5f6a7b8c_file_storage_backend.py @@ -0,0 +1,152 @@ +"""file storage backend + +Revision ID: 3d4e5f6a7b8c +Revises: 2c3d4e5f6a7b +Create Date: 2026-06-12 00:00:00.000000 +""" +from __future__ import annotations + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + +revision: str = "3d4e5f6a7b8c" +down_revision: Union[str, None] = "2c3d4e5f6a7b" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + "file_blobs", + sa.Column("id", sa.String(length=36), nullable=False), + sa.Column("tenant_id", sa.String(length=36), nullable=False), + sa.Column("storage_backend", sa.String(length=50), nullable=False), + sa.Column("storage_bucket", sa.String(length=255), nullable=True), + sa.Column("storage_key", sa.String(length=1000), nullable=False), + sa.Column("checksum_sha256", sa.String(length=64), nullable=False), + sa.Column("size_bytes", sa.Integer(), nullable=False), + sa.Column("content_type", sa.String(length=255), nullable=True), + sa.Column("ref_count", sa.Integer(), nullable=False, server_default="1"), + sa.Column("retained_until", sa.DateTime(timezone=True), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(["tenant_id"], ["tenants.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("tenant_id", "checksum_sha256", "size_bytes", name="uq_file_blobs_tenant_checksum_size"), + ) + op.create_index(op.f("ix_file_blobs_tenant_id"), "file_blobs", ["tenant_id"]) + op.create_index(op.f("ix_file_blobs_checksum_sha256"), "file_blobs", ["checksum_sha256"]) + + op.create_table( + "file_assets", + sa.Column("id", sa.String(length=36), nullable=False), + sa.Column("tenant_id", sa.String(length=36), nullable=False), + sa.Column("owner_type", sa.String(length=20), nullable=False), + sa.Column("owner_user_id", sa.String(length=36), nullable=True), + sa.Column("owner_group_id", sa.String(length=36), nullable=True), + sa.Column("current_version_id", sa.String(length=36), nullable=True), + sa.Column("display_path", sa.String(length=1000), nullable=False), + sa.Column("filename", sa.String(length=500), nullable=False), + sa.Column("description", sa.Text(), nullable=True), + sa.Column("created_by_user_id", sa.String(length=36), nullable=True), + sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("metadata", sa.JSON(), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(["created_by_user_id"], ["users.id"], ondelete="SET NULL"), + sa.ForeignKeyConstraint(["owner_group_id"], ["groups.id"], ondelete="SET NULL"), + sa.ForeignKeyConstraint(["owner_user_id"], ["users.id"], ondelete="SET NULL"), + sa.ForeignKeyConstraint(["tenant_id"], ["tenants.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + ) + for col in ["tenant_id", "owner_type", "owner_user_id", "owner_group_id", "current_version_id", "display_path", "filename", "created_by_user_id", "deleted_at"]: + op.create_index(op.f(f"ix_file_assets_{col}"), "file_assets", [col]) + + op.create_table( + "file_versions", + sa.Column("id", sa.String(length=36), nullable=False), + sa.Column("tenant_id", sa.String(length=36), nullable=False), + sa.Column("file_asset_id", sa.String(length=36), nullable=False), + sa.Column("blob_id", sa.String(length=36), nullable=False), + sa.Column("version_number", sa.Integer(), nullable=False), + sa.Column("filename_at_upload", sa.String(length=500), nullable=False), + sa.Column("display_path_at_upload", sa.String(length=1000), nullable=False), + sa.Column("content_type", sa.String(length=255), nullable=True), + sa.Column("size_bytes", sa.Integer(), nullable=False), + sa.Column("checksum_sha256", sa.String(length=64), nullable=False), + sa.Column("created_by_user_id", sa.String(length=36), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(["blob_id"], ["file_blobs.id"], ondelete="RESTRICT"), + sa.ForeignKeyConstraint(["created_by_user_id"], ["users.id"], ondelete="SET NULL"), + sa.ForeignKeyConstraint(["file_asset_id"], ["file_assets.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["tenant_id"], ["tenants.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("file_asset_id", "version_number", name="uq_file_versions_asset_number"), + ) + for col in ["tenant_id", "file_asset_id", "blob_id", "checksum_sha256", "created_by_user_id"]: + op.create_index(op.f(f"ix_file_versions_{col}"), "file_versions", [col]) + + op.create_table( + "file_shares", + sa.Column("id", sa.String(length=36), nullable=False), + sa.Column("tenant_id", sa.String(length=36), nullable=False), + sa.Column("file_asset_id", sa.String(length=36), nullable=False), + sa.Column("target_type", sa.String(length=20), nullable=False), + sa.Column("target_id", sa.String(length=36), nullable=False), + sa.Column("permission", sa.String(length=20), nullable=False, server_default="read"), + sa.Column("created_by_user_id", sa.String(length=36), nullable=True), + sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(["created_by_user_id"], ["users.id"], ondelete="SET NULL"), + sa.ForeignKeyConstraint(["file_asset_id"], ["file_assets.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["tenant_id"], ["tenants.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("file_asset_id", "target_type", "target_id", "revoked_at", name="uq_file_shares_active_target"), + ) + for col in ["tenant_id", "file_asset_id", "target_type", "target_id", "created_by_user_id", "revoked_at"]: + op.create_index(op.f(f"ix_file_shares_{col}"), "file_shares", [col]) + + op.create_table( + "campaign_attachment_uses", + sa.Column("id", sa.String(length=36), nullable=False), + sa.Column("tenant_id", sa.String(length=36), nullable=False), + sa.Column("campaign_id", sa.String(length=36), nullable=False), + sa.Column("campaign_version_id", sa.String(length=36), nullable=False), + sa.Column("campaign_job_id", sa.String(length=36), nullable=True), + sa.Column("entry_index", sa.Integer(), nullable=True), + sa.Column("entry_id", sa.String(length=255), nullable=True), + sa.Column("file_asset_id", sa.String(length=36), nullable=False), + sa.Column("file_version_id", sa.String(length=36), nullable=False), + sa.Column("file_blob_id", sa.String(length=36), nullable=False), + sa.Column("filename_used", sa.String(length=500), nullable=False), + sa.Column("checksum_sha256", sa.String(length=64), nullable=False), + sa.Column("size_bytes", sa.Integer(), nullable=False), + sa.Column("content_type", sa.String(length=255), nullable=True), + sa.Column("use_stage", sa.String(length=20), nullable=False, server_default="built"), + sa.Column("used_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(["campaign_id"], ["campaigns.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["campaign_job_id"], ["campaign_jobs.id"], ondelete="SET NULL"), + sa.ForeignKeyConstraint(["campaign_version_id"], ["campaign_versions.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["file_asset_id"], ["file_assets.id"], ondelete="RESTRICT"), + sa.ForeignKeyConstraint(["file_blob_id"], ["file_blobs.id"], ondelete="RESTRICT"), + sa.ForeignKeyConstraint(["file_version_id"], ["file_versions.id"], ondelete="RESTRICT"), + sa.ForeignKeyConstraint(["tenant_id"], ["tenants.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("campaign_job_id", "file_version_id", "filename_used", "use_stage", name="uq_campaign_attachment_uses_job_file_stage"), + ) + for col in ["tenant_id", "campaign_id", "campaign_version_id", "campaign_job_id", "entry_id", "file_asset_id", "file_version_id", "file_blob_id", "use_stage", "used_at"]: + op.create_index(op.f(f"ix_campaign_attachment_uses_{col}"), "campaign_attachment_uses", [col]) + + +def downgrade() -> None: + op.drop_table("campaign_attachment_uses") + op.drop_table("file_shares") + op.drop_table("file_versions") + op.drop_table("file_assets") + op.drop_table("file_blobs") diff --git a/server/alembic/versions/4e5f6a7b8c9d_file_folders.py b/server/alembic/versions/4e5f6a7b8c9d_file_folders.py new file mode 100644 index 0000000..afed0f3 --- /dev/null +++ b/server/alembic/versions/4e5f6a7b8c9d_file_folders.py @@ -0,0 +1,45 @@ +"""file folders + +Revision ID: 4e5f6a7b8c9d +Revises: 3d4e5f6a7b8c +Create Date: 2026-06-12 00:30:00.000000 +""" +from __future__ import annotations + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + +revision: str = "4e5f6a7b8c9d" +down_revision: Union[str, None] = "3d4e5f6a7b8c" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + "file_folders", + sa.Column("id", sa.String(length=36), nullable=False), + sa.Column("tenant_id", sa.String(length=36), nullable=False), + sa.Column("owner_type", sa.String(length=20), nullable=False), + sa.Column("owner_user_id", sa.String(length=36), nullable=True), + sa.Column("owner_group_id", sa.String(length=36), nullable=True), + sa.Column("path", sa.String(length=1000), nullable=False), + sa.Column("created_by_user_id", sa.String(length=36), nullable=True), + sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("metadata", sa.JSON(), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(["created_by_user_id"], ["users.id"], ondelete="SET NULL"), + sa.ForeignKeyConstraint(["owner_group_id"], ["groups.id"], ondelete="SET NULL"), + sa.ForeignKeyConstraint(["owner_user_id"], ["users.id"], ondelete="SET NULL"), + sa.ForeignKeyConstraint(["tenant_id"], ["tenants.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + ) + for col in ["tenant_id", "owner_type", "owner_user_id", "owner_group_id", "path", "created_by_user_id", "deleted_at"]: + op.create_index(op.f(f"ix_file_folders_{col}"), "file_folders", [col]) + + +def downgrade() -> None: + op.drop_table("file_folders") diff --git a/server/app/api/v1/__init__.py b/server/app/api/v1/__init__.py index b0cec4e..573e085 100644 --- a/server/app/api/v1/__init__.py +++ b/server/app/api/v1/__init__.py @@ -6,6 +6,8 @@ from .campaigns import router as campaigns_router from .audit import router as audit_router from .system import router as system_router from .mail import router as mail_router +from .files import router as files_router +from .dev_mail import router as dev_mail_router router = APIRouter(prefix="/api/v1") router.include_router(auth_router) @@ -14,3 +16,5 @@ router.include_router(admin_router) router.include_router(audit_router) router.include_router(system_router) router.include_router(mail_router) +router.include_router(files_router) +router.include_router(dev_mail_router) diff --git a/server/app/api/v1/campaigns.py b/server/app/api/v1/campaigns.py index b258208..c1f2ace 100644 --- a/server/app/api/v1/campaigns.py +++ b/server/app/api/v1/campaigns.py @@ -1,7 +1,11 @@ from __future__ import annotations +import copy +import re + from fastapi import APIRouter, Depends, HTTPException, Response, status from sqlalchemy.orm import Session +from pydantic import BaseModel, Field from app.api.v1.schemas import ( BuildCampaignRequest, @@ -34,12 +38,14 @@ from app.mailer.persistence.campaigns import ( create_campaign_version_from_json, validate_campaign_version, ) +from app.storage.services import list_assets_for_user, resolve_patterns from app.mailer.persistence.versions import ( LockedCampaignVersionError, create_minimal_campaign, fork_campaign_version_for_edit, is_version_final_locked, is_user_locked_version, + is_version_locked, get_campaign_version_for_tenant, publish_campaign_version, unlock_validated_campaign_version, @@ -67,6 +73,37 @@ def _get_version_for_tenant(session: Session, version_id: str, tenant_id: str) - return version + + +def _sync_campaign_metadata_to_current_version(session: Session, campaign: Campaign) -> None: + """Keep editable version JSON aligned with version-independent campaign metadata. + + Campaign metadata can be edited from the overview while individual campaign + sections save the current version JSON later. Without this sync, a later + version save can re-apply stale `campaign.name` / `campaign.id` values from + raw_json and make the old overview metadata appear to come back. Audit-safe + or validation-locked versions are left untouched. + """ + + if not campaign.current_version_id: + return + + version = session.get(CampaignVersion, campaign.current_version_id) + if not version or version.campaign_id != campaign.id or is_version_locked(version): + return + + raw_json = copy.deepcopy(version.raw_json if isinstance(version.raw_json, dict) else {}) + campaign_section = raw_json.get("campaign") if isinstance(raw_json.get("campaign"), dict) else {} + raw_json["campaign"] = { + **campaign_section, + "id": campaign.external_id, + "name": campaign.name, + "description": campaign.description or "", + } + version.raw_json = raw_json + session.add(version) + + @router.post("", response_model=CampaignCreateResponse) def create_campaign( payload: CampaignCreateRequest, @@ -187,6 +224,8 @@ def update_campaign_metadata_endpoint( campaign.status = payload.status if payload.description is not None: campaign.description = payload.description + + _sync_campaign_metadata_to_current_version(session, campaign) session.add(campaign) session.commit() session.refresh(campaign) @@ -690,7 +729,10 @@ from app.api.v1.schemas import ( QueueCampaignResponse, SendCampaignNowRequest, SendCampaignNowResponse, + MockCampaignSendRequest, + MockCampaignSendResponse, ) +from app.mailer.dev.mock_campaign import MockCampaignSendError, run_mock_campaign_send from app.mailer.sending.jobs import ( QueueingError, cancel_campaign_jobs, @@ -734,6 +776,55 @@ def queue_campaign( raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc +@router.post("/{campaign_id}/mock-send", response_model=MockCampaignSendResponse) +def mock_send_campaign( + campaign_id: str, + payload: MockCampaignSendRequest | None = None, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_scope("campaign:send")), +): + """Run a fully visible mock delivery flow without mutating campaign state. + + The route validates and builds the selected version, then optionally records + mock SMTP deliveries and mock IMAP appends. It never talks to the configured + real SMTP/IMAP servers and it does not mark the version sent/final. + """ + + payload = payload or MockCampaignSendRequest() + try: + result = run_mock_campaign_send( + session, + tenant_id=principal.tenant_id, + campaign_id=campaign_id, + version_id=payload.version_id, + send=payload.send, + include_warnings=payload.include_warnings, + include_needs_review=payload.include_needs_review, + append_sent=payload.append_sent, + clear_mailbox=payload.clear_mailbox, + check_files=payload.check_files, + ) + audit_from_principal( + session, + principal, + action="campaign.mock_send" if payload.send else "campaign.mock_send_review", + object_type="campaign", + object_id=campaign_id, + details={ + "version_id": result.get("version_id"), + "send_requested": payload.send, + "sent_count": result.get("send", {}).get("sent_count"), + "failed_count": result.get("send", {}).get("failed_count"), + }, + commit=True, + ) + return MockCampaignSendResponse(result=result) + except MockCampaignSendError as exc: + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc + except Exception as exc: + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc + + @router.post("/{campaign_id}/send-now", response_model=SendCampaignNowResponse) def send_campaign_now_endpoint( campaign_id: str, @@ -756,7 +847,7 @@ def send_campaign_now_endpoint( 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 + build_result: dict[str, object] | None = version.build_summary if isinstance(version.build_summary, dict) else None if is_user_locked_version(version): raise HTTPException( status_code=status.HTTP_409_CONFLICT, @@ -767,7 +858,7 @@ def send_campaign_now_endpoint( 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: + if not build_result: raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Campaign version must be built before dry-run or sending.", @@ -874,3 +965,129 @@ def append_sent( return CampaignActionResponse(result=result) except QueueingError as exc: raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc + + +class CampaignAttachmentPreviewRequest(BaseModel): + include_unmatched: bool = True + + +class CampaignAttachmentPreviewResponse(BaseModel): + campaign_id: str + version_id: str + shared_file_count: int + rules: list[dict[str, object]] = Field(default_factory=list) + unused_shared_files: list[dict[str, object]] = Field(default_factory=list) + + +_BRACE_PLACEHOLDER_RE = re.compile(r"(? str: + def value_for(raw_key: str) -> str: + key = raw_key.strip() + if key.startswith("local:"): + value = entry_fields.get(key.removeprefix("local:"), "") + elif key.startswith("local."): + value = entry_fields.get(key.removeprefix("local."), "") + elif key.startswith("global:"): + value = global_values.get(key.removeprefix("global:"), "") + elif key.startswith("global."): + value = global_values.get(key.removeprefix("global."), "") + else: + value = entry_fields.get(key, global_values.get(key, "")) + return "" if value is None else str(value) + + rendered = _BRACE_PLACEHOLDER_RE.sub(lambda match: value_for(match.group(1)), template) + return _DOLLAR_PLACEHOLDER_RE.sub(lambda match: value_for(match.group(1)), rendered) + + +def _rule_pattern(rule: dict[str, object], base_path_names: set[str], *, global_values: dict[str, object], entry_fields: dict[str, object]) -> str: + base_dir = _preview_render_template(str(rule.get("base_dir") or "."), global_values=global_values, entry_fields=entry_fields).strip().strip("/") + file_filter = _preview_render_template(str(rule.get("file_filter") or "*"), global_values=global_values, entry_fields=entry_fields).strip() or "*" + if not base_dir or base_dir == "." or base_dir in base_path_names: + return file_filter + return f"{base_dir}/{file_filter}" + + +def _file_preview(asset) -> dict[str, object]: + return { + "id": asset.id, + "display_path": asset.display_path, + "filename": asset.filename, + "owner_type": asset.owner_type, + "owner_id": asset.owner_user_id if asset.owner_type == "user" else asset.owner_group_id, + } + + +@router.post("/{campaign_id}/versions/{version_id}/attachments/preview", response_model=CampaignAttachmentPreviewResponse) +def preview_campaign_attachments( + campaign_id: str, + version_id: str, + payload: CampaignAttachmentPreviewRequest | None = None, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_scope("attachments:read")), +): + campaign = _get_campaign_for_tenant(session, campaign_id, principal.tenant_id) + version = _get_version_for_tenant(session, version_id, principal.tenant_id) + if version.campaign_id != campaign.id: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Campaign version not found") + + raw = version.raw_json if isinstance(version.raw_json, dict) else {} + attachments = raw.get("attachments") if isinstance(raw.get("attachments"), dict) else {} + base_paths = attachments.get("base_paths") if isinstance(attachments.get("base_paths"), list) else [] + base_path_names = {str(item.get("name")) for item in base_paths if isinstance(item, dict) and item.get("name")} + global_values = raw.get("global_values") if isinstance(raw.get("global_values"), dict) else {} + rules: list[dict[str, object]] = [] + + global_rules = attachments.get("global") if isinstance(attachments.get("global"), list) else [] + for index, rule in enumerate(global_rules): + if not isinstance(rule, dict): + continue + rules.append({ + "source": "global", + "index": index, + "label": rule.get("label"), + "required": bool(rule.get("required", True)), + "pattern": _rule_pattern(rule, base_path_names, global_values=global_values, entry_fields={}), + }) + + entries = raw.get("entries") if isinstance(raw.get("entries"), dict) else {} + inline_entries = entries.get("inline") if isinstance(entries.get("inline"), list) else [] + for entry_index, entry in enumerate(inline_entries, start=1): + if not isinstance(entry, dict): + continue + entry_fields = entry.get("fields") if isinstance(entry.get("fields"), dict) else {} + entry_rules = entry.get("attachments") if isinstance(entry.get("attachments"), list) else [] + for rule_index, rule in enumerate(entry_rules): + if not isinstance(rule, dict): + continue + rules.append({ + "source": "entry", + "entry_index": entry_index, + "entry_id": entry.get("id"), + "index": rule_index, + "label": rule.get("label"), + "required": bool(rule.get("required", True)), + "pattern": _rule_pattern(rule, base_path_names, global_values=global_values, entry_fields=entry_fields), + }) + + shared_assets = list_assets_for_user( + session, + tenant_id=principal.tenant_id, + user_id=principal.user.id, + campaign_id=campaign.id, + is_admin=principal.user.is_tenant_admin or "*" in set(principal.scopes or []), + ) + resolved, unmatched = resolve_patterns(shared_assets, [str(rule["pattern"]) for rule in rules]) + for rule, result in zip(rules, resolved, strict=False): + rule["matches"] = [_file_preview(asset) for asset in result.matches] + rule["match_count"] = len(result.matches) + + return CampaignAttachmentPreviewResponse( + campaign_id=campaign.id, + version_id=version.id, + shared_file_count=len(shared_assets), + rules=rules, + unused_shared_files=[_file_preview(asset) for asset in unmatched] if payload is None or payload.include_unmatched else [], + ) diff --git a/server/app/api/v1/dev_mail.py b/server/app/api/v1/dev_mail.py new file mode 100644 index 0000000..2469a64 --- /dev/null +++ b/server/app/api/v1/dev_mail.py @@ -0,0 +1,95 @@ +from __future__ import annotations + +from typing import Any + +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel, ConfigDict, Field + +from app.auth.dependencies import ApiPrincipal, require_scope +from app.mailer.dev.mock_mailbox import ( + clear_records, + get_failures, + get_record, + list_records, + set_failures, +) + +router = APIRouter(prefix="/dev/mailbox", tags=["dev-mailbox"]) + + +class MockMailboxListResponse(BaseModel): + messages: list[dict[str, Any]] + + +class MockMailboxMessageResponse(BaseModel): + message: dict[str, Any] + + +class MockMailboxClearResponse(BaseModel): + deleted_count: int + + +class MockMailboxFailureConfig(BaseModel): + model_config = ConfigDict(extra="forbid") + + fail_next_smtp: bool | None = None + fail_next_imap: bool | None = None + smtp_reject_recipients_containing: str | None = Field(default=None, max_length=255) + + +class MockMailboxFailureResponse(BaseModel): + config: dict[str, Any] + + +@router.get("/messages", response_model=MockMailboxListResponse) +def list_mock_mailbox_messages( + kind: str | None = None, + limit: int = 100, + principal: ApiPrincipal = Depends(require_scope("campaign:read")), +): + """List messages captured by the integrated development mail sandbox.""" + + del principal + return MockMailboxListResponse(messages=list_records(kind=kind, limit=limit)) + + +@router.get("/messages/{message_id}", response_model=MockMailboxMessageResponse) +def get_mock_mailbox_message( + message_id: str, + principal: ApiPrincipal = Depends(require_scope("campaign:read")), +): + del principal + message = get_record(message_id, include_raw=True) + if not message: + raise HTTPException(status_code=404, detail="Mock mailbox message not found") + return MockMailboxMessageResponse(message=message) + + +@router.delete("/messages", response_model=MockMailboxClearResponse) +def clear_mock_mailbox_messages( + principal: ApiPrincipal = Depends(require_scope("campaign:write")), +): + del principal + return MockMailboxClearResponse(deleted_count=clear_records()) + + +@router.get("/failures", response_model=MockMailboxFailureResponse) +def get_mock_failure_config( + principal: ApiPrincipal = Depends(require_scope("campaign:read")), +): + del principal + return MockMailboxFailureResponse(config=get_failures()) + + +@router.post("/failures", response_model=MockMailboxFailureResponse) +def update_mock_failure_config( + payload: MockMailboxFailureConfig, + principal: ApiPrincipal = Depends(require_scope("campaign:write")), +): + del principal + config = set_failures( + fail_next_smtp=payload.fail_next_smtp, + fail_next_imap=payload.fail_next_imap, + smtp_reject_recipients_containing=payload.smtp_reject_recipients_containing, + ) + return MockMailboxFailureResponse(config=config) diff --git a/server/app/api/v1/files.py b/server/app/api/v1/files.py new file mode 100644 index 0000000..b70b0bb --- /dev/null +++ b/server/app/api/v1/files.py @@ -0,0 +1,641 @@ +from __future__ import annotations + +from io import BytesIO +from typing import Any, Literal + +from fastapi import APIRouter, Depends, File as FastAPIFile, Form, HTTPException, UploadFile, status +from fastapi.responses import StreamingResponse +from pydantic import BaseModel, Field +from sqlalchemy.orm import Session + +from app.auth.dependencies import ApiPrincipal, require_scope +from app.db.models import Campaign, FileAsset, FileFolder, FileShare, Group +from app.db.session import get_session +from app.storage.paths import UnsafeFilePathError, filename_from_path, normalize_folder, normalize_logical_path +from app.storage.services import ( + FileStorageError, + asset_is_audit_relevant, + build_rename_preview, + create_file_asset, + create_folder, + create_zip_bytes, + current_version_and_blob, + extract_zip_upload, + get_asset_for_user, + list_assets_for_user, + list_folders_for_user, + rename_asset, + resolve_patterns, + share_file, + soft_delete_assets, + soft_delete_folder, + user_group_ids, + read_asset_bytes, +) + +router = APIRouter(prefix="/files", tags=["files"]) + + +class FileSpaceResponse(BaseModel): + id: str + label: str + owner_type: Literal["user", "group"] + owner_id: str + description: str | None = None + + +class FileSpacesResponse(BaseModel): + spaces: list[FileSpaceResponse] + + +class FileShareResponse(BaseModel): + id: str + target_type: str + target_id: str + permission: str + created_at: str + revoked_at: str | None = None + + +class FileAssetResponse(BaseModel): + id: str + tenant_id: str + owner_type: str + owner_id: str + display_path: str + filename: str + description: str | None = None + size_bytes: int + content_type: str | None = None + checksum_sha256: str + version_id: str + created_at: str + updated_at: str + deleted_at: str | None = None + audit_relevant: bool = False + metadata: dict[str, Any] | None = None + shares: list[FileShareResponse] = Field(default_factory=list) + + +class FileFolderResponse(BaseModel): + id: str + tenant_id: str + owner_type: str + owner_id: str + path: str + created_at: str + updated_at: str + deleted_at: str | None = None + + +class FileFoldersResponse(BaseModel): + folders: list[FileFolderResponse] + + +class FileFolderCreateRequest(BaseModel): + owner_type: Literal["user", "group"] + owner_id: str + path: str + + +class FileFolderDeleteRequest(BaseModel): + owner_type: Literal["user", "group"] + owner_id: str + path: str + recursive: bool = True + + +class FileFolderDeleteResponse(BaseModel): + deleted_folders: int + deleted_files: int + + +class FileListResponse(BaseModel): + files: list[FileAssetResponse] + + +class FileUploadResponse(BaseModel): + files: list[FileAssetResponse] + + +class BulkDeleteRequest(BaseModel): + file_ids: list[str] + + +class BulkDeleteResponse(BaseModel): + deleted_count: int + + +class FileShareRequest(BaseModel): + target_type: Literal["user", "group", "campaign", "tenant"] + target_id: str + permission: Literal["read", "write", "manage"] = "read" + + +class RenameRequest(BaseModel): + file_ids: list[str] + mode: Literal["prefix", "suffix", "replace"] + find: str | None = None + replacement: str = "" + prefix: str = "" + suffix: str = "" + dry_run: bool = True + + +class RenamePreviewItem(BaseModel): + file_id: str + old_path: str + new_path: str + + +class RenameResponse(BaseModel): + dry_run: bool + items: list[RenamePreviewItem] + + +class ArchiveRequest(BaseModel): + file_ids: list[str] + filename: str = "files.zip" + + +class PatternResolveRequest(BaseModel): + patterns: list[str] + owner_type: Literal["user", "group"] | None = None + owner_id: str | None = None + campaign_id: str | None = None + path_prefix: str | None = None + include_unmatched: bool = True + + +class PatternMatchResponse(BaseModel): + pattern: str + matches: list[FileAssetResponse] + + +class PatternResolveResponse(BaseModel): + patterns: list[PatternMatchResponse] + unmatched: list[FileAssetResponse] = Field(default_factory=list) + + +def _is_admin(principal: ApiPrincipal) -> bool: + return principal.user.is_tenant_admin or "*" in set(principal.scopes or []) + + +def _http_error(exc: Exception, *, not_found: bool = False) -> HTTPException: + code = status.HTTP_404_NOT_FOUND if not_found else status.HTTP_400_BAD_REQUEST + return HTTPException(status_code=code, detail=str(exc)) + + +def _owner_id(asset: FileAsset) -> str: + return asset.owner_user_id if asset.owner_type == "user" else asset.owner_group_id # type: ignore[return-value] + + +def _asset_response(session: Session, asset: FileAsset, *, include_shares: bool = False) -> FileAssetResponse: + version, blob = current_version_and_blob(session, asset) + shares: list[FileShareResponse] = [] + if include_shares: + rows = session.query(FileShare).filter(FileShare.file_asset_id == asset.id).order_by(FileShare.created_at.desc()).all() + shares = [ + FileShareResponse( + id=row.id, + target_type=row.target_type, + target_id=row.target_id, + permission=row.permission, + created_at=row.created_at.isoformat(), + revoked_at=row.revoked_at.isoformat() if row.revoked_at else None, + ) + for row in rows + ] + return FileAssetResponse( + id=asset.id, + tenant_id=asset.tenant_id, + owner_type=asset.owner_type, + owner_id=_owner_id(asset), + display_path=asset.display_path, + filename=asset.filename, + description=asset.description, + size_bytes=blob.size_bytes, + content_type=blob.content_type, + checksum_sha256=blob.checksum_sha256, + version_id=version.id, + created_at=asset.created_at.isoformat(), + updated_at=asset.updated_at.isoformat(), + deleted_at=asset.deleted_at.isoformat() if asset.deleted_at else None, + audit_relevant=asset_is_audit_relevant(session, asset), + metadata=asset.metadata_ or {}, + shares=shares, + ) + + +def _folder_owner_id(folder: FileFolder) -> str: + return folder.owner_user_id if folder.owner_type == "user" else folder.owner_group_id # type: ignore[return-value] + + +def _folder_response(folder: FileFolder) -> FileFolderResponse: + return FileFolderResponse( + id=folder.id, + tenant_id=folder.tenant_id, + owner_type=folder.owner_type, + owner_id=_folder_owner_id(folder), + path=folder.path, + created_at=folder.created_at.isoformat(), + updated_at=folder.updated_at.isoformat(), + deleted_at=folder.deleted_at.isoformat() if folder.deleted_at else None, + ) + + +def _ensure_list_owner_access(session: Session, principal: ApiPrincipal, owner_type: str | None, owner_id: str | None) -> None: + if not owner_type: + return + if owner_type == "user" and owner_id and owner_id != principal.user.id and not _is_admin(principal): + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="No access to this user file space") + if owner_type == "group" and owner_id: + try: + from app.storage.services import ensure_group_access + + ensure_group_access( + session, + tenant_id=principal.tenant_id, + group_id=owner_id, + user_id=principal.user.id, + is_admin=_is_admin(principal), + ) + except FileStorageError as exc: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(exc)) from exc + + +@router.get("/spaces", response_model=FileSpacesResponse) +def list_file_spaces( + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_scope("attachments:read")), +): + spaces = [ + FileSpaceResponse( + id=f"user:{principal.user.id}", + label="My files", + owner_type="user", + owner_id=principal.user.id, + description="Files owned by your user account.", + ) + ] + group_ids = user_group_ids(session, tenant_id=principal.tenant_id, user_id=principal.user.id, include_admin_groups=_is_admin(principal)) + if group_ids: + groups = session.query(Group).filter(Group.tenant_id == principal.tenant_id, Group.id.in_(group_ids)).order_by(Group.name.asc()).all() + spaces.extend( + FileSpaceResponse( + id=f"group:{group.id}", + label=f"{group.name} files", + owner_type="group", + owner_id=group.id, + description="Files owned by this group.", + ) + for group in groups + ) + return FileSpacesResponse(spaces=spaces) + + +@router.get("/folders", response_model=FileFoldersResponse) +def list_file_folders( + owner_type: Literal["user", "group"], + owner_id: str, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_scope("attachments:read")), +): + try: + folders = list_folders_for_user( + session, + tenant_id=principal.tenant_id, + user_id=principal.user.id, + owner_type=owner_type, + owner_id=owner_id, + is_admin=_is_admin(principal), + ) + return FileFoldersResponse(folders=[_folder_response(folder) for folder in folders]) + except FileStorageError as exc: + raise _http_error(exc) from exc + + +@router.post("/folders", response_model=FileFolderResponse) +def create_file_folder( + payload: FileFolderCreateRequest, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_scope("attachments:write")), +): + try: + folder = create_folder( + session, + tenant_id=principal.tenant_id, + owner_type=payload.owner_type, + owner_id=payload.owner_id, + user_id=principal.user.id, + path=payload.path, + is_admin=_is_admin(principal), + ) + session.commit() + return _folder_response(folder) + except (FileStorageError, UnsafeFilePathError, ValueError) as exc: + session.rollback() + raise _http_error(exc) from exc + + +@router.post("/folders/delete", response_model=FileFolderDeleteResponse) +def delete_file_folder( + payload: FileFolderDeleteRequest, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_scope("attachments:write")), +): + try: + deleted_folders, deleted_files = soft_delete_folder( + session, + tenant_id=principal.tenant_id, + owner_type=payload.owner_type, + owner_id=payload.owner_id, + user_id=principal.user.id, + path=payload.path, + recursive=payload.recursive, + is_admin=_is_admin(principal), + ) + session.commit() + return FileFolderDeleteResponse(deleted_folders=deleted_folders, deleted_files=deleted_files) + except (FileStorageError, UnsafeFilePathError, ValueError) as exc: + session.rollback() + raise _http_error(exc) from exc + + +@router.get("", response_model=FileListResponse) +def list_files( + owner_type: Literal["user", "group"] | None = None, + owner_id: str | None = None, + campaign_id: str | None = None, + path_prefix: str | None = None, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_scope("attachments:read")), +): + _ensure_list_owner_access(session, principal, owner_type, owner_id) + assets = list_assets_for_user( + session, + tenant_id=principal.tenant_id, + user_id=principal.user.id, + owner_type=owner_type, + owner_id=owner_id, + campaign_id=campaign_id, + path_prefix=path_prefix, + is_admin=_is_admin(principal), + ) + return FileListResponse(files=[_asset_response(session, asset, include_shares=True) for asset in assets]) + + +@router.post("/upload", response_model=FileUploadResponse) +async def upload_files( + files: list[UploadFile] = FastAPIFile(...), + owner_type: Literal["user", "group"] = Form(default="user"), + owner_id: str | None = Form(default=None), + path: str = Form(default=""), + campaign_id: str | None = Form(default=None), + unpack_zip: bool = Form(default=False), + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_scope("attachments:write")), +): + target_owner = owner_id or principal.user.id + uploaded_assets: list[FileAsset] = [] + try: + for upload in files: + data = await upload.read() + filename = upload.filename or "file" + content_type = upload.content_type or None + if unpack_zip and filename.lower().endswith(".zip"): + extracted = extract_zip_upload( + session, + tenant_id=principal.tenant_id, + owner_type=owner_type, + owner_id=target_owner, + user_id=principal.user.id, + zip_data=data, + folder=path, + campaign_id=campaign_id, + is_admin=_is_admin(principal), + ) + uploaded_assets.extend(item.asset for item in extracted) + continue + stored = create_file_asset( + session, + tenant_id=principal.tenant_id, + owner_type=owner_type, + owner_id=target_owner, + user_id=principal.user.id, + filename=filename, + data=data, + folder=path, + content_type=content_type, + campaign_id=campaign_id, + is_admin=_is_admin(principal), + ) + uploaded_assets.append(stored.asset) + session.commit() + except (FileStorageError, UnsafeFilePathError, ValueError) as exc: + session.rollback() + raise _http_error(exc) from exc + return FileUploadResponse(files=[_asset_response(session, asset, include_shares=True) for asset in uploaded_assets]) + + +@router.post("/upload-zip", response_model=FileUploadResponse) +async def upload_zip( + file: UploadFile = FastAPIFile(...), + owner_type: Literal["user", "group"] = Form(default="user"), + owner_id: str | None = Form(default=None), + path: str = Form(default=""), + campaign_id: str | None = Form(default=None), + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_scope("attachments:write")), +): + data = await file.read() + target_owner = owner_id or principal.user.id + try: + extracted = extract_zip_upload( + session, + tenant_id=principal.tenant_id, + owner_type=owner_type, + owner_id=target_owner, + user_id=principal.user.id, + zip_data=data, + folder=path, + campaign_id=campaign_id, + is_admin=_is_admin(principal), + ) + session.commit() + except (FileStorageError, UnsafeFilePathError, ValueError) as exc: + session.rollback() + raise _http_error(exc) from exc + return FileUploadResponse(files=[_asset_response(session, item.asset, include_shares=True) for item in extracted]) + + +@router.get("/{file_id}", response_model=FileAssetResponse) +def get_file( + file_id: str, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_scope("attachments:read")), +): + try: + asset = get_asset_for_user(session, tenant_id=principal.tenant_id, user_id=principal.user.id, asset_id=file_id, is_admin=_is_admin(principal)) + return _asset_response(session, asset, include_shares=True) + except FileStorageError as exc: + raise _http_error(exc, not_found=True) from exc + + +@router.get("/{file_id}/download") +def download_file( + file_id: str, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_scope("attachments:read")), +): + try: + asset = get_asset_for_user(session, tenant_id=principal.tenant_id, user_id=principal.user.id, asset_id=file_id, is_admin=_is_admin(principal)) + data, _, blob = read_asset_bytes(session, asset) + except FileStorageError as exc: + raise _http_error(exc, not_found=True) from exc + headers = {"Content-Disposition": f'attachment; filename="{asset.filename}"'} + return StreamingResponse(BytesIO(data), media_type=blob.content_type or "application/octet-stream", headers=headers) + + +@router.delete("/{file_id}", response_model=BulkDeleteResponse) +def delete_file( + file_id: str, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_scope("attachments:write")), +): + try: + asset = get_asset_for_user(session, tenant_id=principal.tenant_id, user_id=principal.user.id, asset_id=file_id, require_write=True, is_admin=_is_admin(principal)) + count = soft_delete_assets(session, [asset]) + session.commit() + return BulkDeleteResponse(deleted_count=count) + except FileStorageError as exc: + session.rollback() + raise _http_error(exc, not_found=True) from exc + + +@router.post("/bulk-delete", response_model=BulkDeleteResponse) +def bulk_delete_files( + payload: BulkDeleteRequest, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_scope("attachments:write")), +): + try: + assets = [ + get_asset_for_user(session, tenant_id=principal.tenant_id, user_id=principal.user.id, asset_id=file_id, require_write=True, is_admin=_is_admin(principal)) + for file_id in payload.file_ids + ] + count = soft_delete_assets(session, assets) + session.commit() + return BulkDeleteResponse(deleted_count=count) + except FileStorageError as exc: + session.rollback() + raise _http_error(exc) from exc + + +@router.post("/{file_id}/shares", response_model=FileShareResponse) +def create_share( + file_id: str, + payload: FileShareRequest, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_scope("attachments:write")), +): + try: + asset = get_asset_for_user(session, tenant_id=principal.tenant_id, user_id=principal.user.id, asset_id=file_id, require_write=True, is_admin=_is_admin(principal)) + share = share_file( + session, + tenant_id=principal.tenant_id, + asset=asset, + target_type=payload.target_type, + target_id=payload.target_id, + permission=payload.permission, + user_id=principal.user.id, + ) + session.commit() + return FileShareResponse( + id=share.id, + target_type=share.target_type, + target_id=share.target_id, + permission=share.permission, + created_at=share.created_at.isoformat(), + revoked_at=share.revoked_at.isoformat() if share.revoked_at else None, + ) + except FileStorageError as exc: + session.rollback() + raise _http_error(exc) from exc + + +@router.post("/bulk-rename", response_model=RenameResponse) +def bulk_rename( + payload: RenameRequest, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_scope("attachments:write")), +): + try: + assets = [ + get_asset_for_user(session, tenant_id=principal.tenant_id, user_id=principal.user.id, asset_id=file_id, require_write=True, is_admin=_is_admin(principal)) + for file_id in payload.file_ids + ] + previews = [ + RenamePreviewItem( + file_id=asset.id, + old_path=asset.display_path, + new_path=normalize_logical_path(build_rename_preview(asset, mode=payload.mode, find=payload.find, replacement=payload.replacement, prefix=payload.prefix, suffix=payload.suffix)), + ) + for asset in assets + ] + if not payload.dry_run: + by_id = {asset.id: asset for asset in assets} + for item in previews: + rename_asset(by_id[item.file_id], new_path=item.new_path) + session.add(by_id[item.file_id]) + session.commit() + return RenameResponse(dry_run=payload.dry_run, items=previews) + except (FileStorageError, UnsafeFilePathError, ValueError) as exc: + session.rollback() + raise _http_error(exc) from exc + + +@router.post("/archive.zip") +def download_archive( + payload: ArchiveRequest, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_scope("attachments:read")), +): + try: + assets = [ + get_asset_for_user(session, tenant_id=principal.tenant_id, user_id=principal.user.id, asset_id=file_id, is_admin=_is_admin(principal)) + for file_id in payload.file_ids + ] + data = create_zip_bytes(session, assets) + except FileStorageError as exc: + raise _http_error(exc) from exc + filename = filename_from_path(normalize_logical_path(payload.filename, fallback_filename="files.zip")) + headers = {"Content-Disposition": f'attachment; filename="{filename}"'} + return StreamingResponse(BytesIO(data), media_type="application/zip", headers=headers) + + +@router.post("/resolve-patterns", response_model=PatternResolveResponse) +def resolve_file_patterns( + payload: PatternResolveRequest, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_scope("attachments:read")), +): + _ensure_list_owner_access(session, principal, payload.owner_type, payload.owner_id) + try: + assets = list_assets_for_user( + session, + tenant_id=principal.tenant_id, + user_id=principal.user.id, + owner_type=payload.owner_type, + owner_id=payload.owner_id, + campaign_id=payload.campaign_id, + path_prefix=payload.path_prefix, + is_admin=_is_admin(principal), + ) + resolved, unmatched = resolve_patterns(assets, payload.patterns, base_path=payload.path_prefix) + return PatternResolveResponse( + patterns=[PatternMatchResponse(pattern=item.pattern, matches=[_asset_response(session, asset) for asset in item.matches]) for item in resolved], + unmatched=[_asset_response(session, asset) for asset in unmatched] if payload.include_unmatched else [], + ) + except (FileStorageError, UnsafeFilePathError, ValueError) as exc: + raise _http_error(exc) from exc diff --git a/server/app/api/v1/schemas.py b/server/app/api/v1/schemas.py index b7cc698..7ec3d9a 100644 --- a/server/app/api/v1/schemas.py +++ b/server/app/api/v1/schemas.py @@ -227,6 +227,22 @@ class SendCampaignNowResponse(BaseModel): result: dict[str, Any] +class MockCampaignSendRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + version_id: str | None = None + send: bool = False + include_warnings: bool = True + include_needs_review: bool = False + append_sent: bool = True + clear_mailbox: bool = False + check_files: bool = False + + +class MockCampaignSendResponse(BaseModel): + result: dict[str, Any] + + class AppendSentRequest(BaseModel): model_config = ConfigDict(extra="forbid") diff --git a/server/app/db/models.py b/server/app/db/models.py index 1d41040..3e4cc05 100644 --- a/server/app/db/models.py +++ b/server/app/db/models.py @@ -351,6 +351,107 @@ class AttachmentInstance(Base, TimestampMixin): metadata_: Mapped[dict[str, Any] | None] = mapped_column("metadata", JSON, nullable=True) +class FileBlob(Base, TimestampMixin): + __tablename__ = "file_blobs" + __table_args__ = (UniqueConstraint("tenant_id", "checksum_sha256", "size_bytes", name="uq_file_blobs_tenant_checksum_size"),) + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid) + tenant_id: Mapped[str] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True) + storage_backend: Mapped[str] = mapped_column(String(50), nullable=False) + storage_bucket: Mapped[str | None] = mapped_column(String(255)) + storage_key: Mapped[str] = mapped_column(String(1000), nullable=False) + checksum_sha256: Mapped[str] = mapped_column(String(64), nullable=False, index=True) + size_bytes: Mapped[int] = mapped_column(Integer, nullable=False) + content_type: Mapped[str | None] = mapped_column(String(255)) + ref_count: Mapped[int] = mapped_column(Integer, default=1, nullable=False) + retained_until: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + + +class FileFolder(Base, TimestampMixin): + __tablename__ = "file_folders" + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid) + tenant_id: Mapped[str] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True) + owner_type: Mapped[str] = mapped_column(String(20), nullable=False, index=True) + owner_user_id: Mapped[str | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True) + owner_group_id: Mapped[str | None] = mapped_column(ForeignKey("groups.id", ondelete="SET NULL"), nullable=True, index=True) + path: Mapped[str] = mapped_column(String(1000), nullable=False, index=True) + created_by_user_id: Mapped[str | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True) + deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True) + metadata_: Mapped[dict[str, Any] | None] = mapped_column("metadata", JSON, nullable=True) + + +class FileAsset(Base, TimestampMixin): + __tablename__ = "file_assets" + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid) + tenant_id: Mapped[str] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True) + owner_type: Mapped[str] = mapped_column(String(20), nullable=False, index=True) + owner_user_id: Mapped[str | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True) + owner_group_id: Mapped[str | None] = mapped_column(ForeignKey("groups.id", ondelete="SET NULL"), nullable=True, index=True) + current_version_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True) + display_path: Mapped[str] = mapped_column(String(1000), nullable=False, index=True) + filename: Mapped[str] = mapped_column(String(500), nullable=False, index=True) + description: Mapped[str | None] = mapped_column(Text) + created_by_user_id: Mapped[str | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True) + deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True) + metadata_: Mapped[dict[str, Any] | None] = mapped_column("metadata", JSON, nullable=True) + + +class FileVersion(Base, TimestampMixin): + __tablename__ = "file_versions" + __table_args__ = (UniqueConstraint("file_asset_id", "version_number", name="uq_file_versions_asset_number"),) + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid) + tenant_id: Mapped[str] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True) + file_asset_id: Mapped[str] = mapped_column(ForeignKey("file_assets.id", ondelete="CASCADE"), nullable=False, index=True) + blob_id: Mapped[str] = mapped_column(ForeignKey("file_blobs.id", ondelete="RESTRICT"), nullable=False, index=True) + version_number: Mapped[int] = mapped_column(Integer, nullable=False) + filename_at_upload: Mapped[str] = mapped_column(String(500), nullable=False) + display_path_at_upload: Mapped[str] = mapped_column(String(1000), nullable=False) + content_type: Mapped[str | None] = mapped_column(String(255)) + size_bytes: Mapped[int] = mapped_column(Integer, nullable=False) + checksum_sha256: Mapped[str] = mapped_column(String(64), nullable=False, index=True) + created_by_user_id: Mapped[str | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True) + + +class FileShare(Base, TimestampMixin): + __tablename__ = "file_shares" + __table_args__ = (UniqueConstraint("file_asset_id", "target_type", "target_id", "revoked_at", name="uq_file_shares_active_target"),) + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid) + tenant_id: Mapped[str] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True) + file_asset_id: Mapped[str] = mapped_column(ForeignKey("file_assets.id", ondelete="CASCADE"), nullable=False, index=True) + target_type: Mapped[str] = mapped_column(String(20), nullable=False, index=True) + target_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True) + permission: Mapped[str] = mapped_column(String(20), default="read", nullable=False) + created_by_user_id: Mapped[str | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True) + revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True) + + +class CampaignAttachmentUse(Base, TimestampMixin): + __tablename__ = "campaign_attachment_uses" + __table_args__ = (UniqueConstraint("campaign_job_id", "file_version_id", "filename_used", "use_stage", name="uq_campaign_attachment_uses_job_file_stage"),) + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid) + tenant_id: Mapped[str] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True) + campaign_id: Mapped[str] = mapped_column(ForeignKey("campaigns.id", ondelete="CASCADE"), nullable=False, index=True) + campaign_version_id: Mapped[str] = mapped_column(ForeignKey("campaign_versions.id", ondelete="CASCADE"), nullable=False, index=True) + campaign_job_id: Mapped[str | None] = mapped_column(ForeignKey("campaign_jobs.id", ondelete="SET NULL"), nullable=True, index=True) + entry_index: Mapped[int | None] = mapped_column(Integer) + entry_id: Mapped[str | None] = mapped_column(String(255), index=True) + file_asset_id: Mapped[str] = mapped_column(ForeignKey("file_assets.id", ondelete="RESTRICT"), nullable=False, index=True) + file_version_id: Mapped[str] = mapped_column(ForeignKey("file_versions.id", ondelete="RESTRICT"), nullable=False, index=True) + file_blob_id: Mapped[str] = mapped_column(ForeignKey("file_blobs.id", ondelete="RESTRICT"), nullable=False, index=True) + filename_used: Mapped[str] = mapped_column(String(500), nullable=False) + checksum_sha256: Mapped[str] = mapped_column(String(64), nullable=False) + size_bytes: Mapped[int] = mapped_column(Integer, nullable=False) + content_type: Mapped[str | None] = mapped_column(String(255)) + use_stage: Mapped[str] = mapped_column(String(20), default="built", nullable=False, index=True) + used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True) + + + class SendAttempt(Base, TimestampMixin): __tablename__ = "send_attempts" diff --git a/server/app/mailer/attachments/resolver.py b/server/app/mailer/attachments/resolver.py index fe581d1..87b7aa2 100644 --- a/server/app/mailer/attachments/resolver.py +++ b/server/app/mailer/attachments/resolver.py @@ -183,9 +183,14 @@ def _recipient_values(entry: EntryConfig) -> dict[str, str]: def _template_values(config: CampaignConfig, entry: EntryConfig) -> dict[str, Any]: values: dict[str, Any] = {} + for field in config.fields: + values.setdefault(field.name, "") + values.setdefault(f"global::{field.name}", "") + values.setdefault(f"local::{field.name}", "") for key, value in config.global_values.items(): values[f"global::{key}"] = value for key, value in effective_entry_field_values(config, entry).items(): + values[key] = value values[f"local::{key}"] = value if entry.id: values["local::id"] = entry.id diff --git a/server/app/mailer/campaign/models.py b/server/app/mailer/campaign/models.py index 3fa5f86..de5dccc 100644 --- a/server/app/mailer/campaign/models.py +++ b/server/app/mailer/campaign/models.py @@ -213,6 +213,7 @@ class AttachmentBasePathConfig(StrictModel): name: str path: str = "." allow_individual: bool = False + unsent_warning: bool = False # Legacy UI builds briefly wrote a source value. Keep accepting it so older # drafts do not become invalid merely because the current UI no longer shows # or edits that column. @@ -222,11 +223,24 @@ class AttachmentBasePathConfig(StrictModel): class AttachmentConfig(StrictModel): id: str | None = None label: str | None = None + # Legacy UI helper. Current attachment resolution ignores this value and + # treats direct files as plain file_filter patterns without wildcards. + # Keep accepting it so existing drafts with {"type": ""}, "direct" + # or "pattern" remain valid. + type_: str | None = Field(default=None, alias="type") base_dir: str file_filter: str include_subdirs: bool = False required: bool = True allow_multiple: bool = False + + @field_validator("type_", mode="before") + @classmethod + def empty_type_means_unset(cls, value: Any) -> Any: + if value == "": + return None + return value + # None means: inherit from validation_policy. Explicit values remain # supported for backwards compatibility and per-rule overrides. missing_behavior: Behavior | None = None @@ -335,6 +349,7 @@ class ValidationPolicy(StrictModel): missing_optional_attachment: Behavior = Behavior.WARN ambiguous_attachment_match: Behavior = Behavior.ASK ignore_empty_fields: bool = False + unsent_attachment_files: Behavior = Behavior.WARN missing_email: MissingAddressBehavior = MissingAddressBehavior.BLOCK template_error: MissingAddressBehavior = MissingAddressBehavior.BLOCK inactive_entry: InactiveEntryBehavior = InactiveEntryBehavior.DROP diff --git a/server/app/mailer/dev/__init__.py b/server/app/mailer/dev/__init__.py new file mode 100644 index 0000000..ceebce9 --- /dev/null +++ b/server/app/mailer/dev/__init__.py @@ -0,0 +1 @@ +"""Development-only mail sandbox helpers.""" diff --git a/server/app/mailer/dev/mock_campaign.py b/server/app/mailer/dev/mock_campaign.py new file mode 100644 index 0000000..1bdb495 --- /dev/null +++ b/server/app/mailer/dev/mock_campaign.py @@ -0,0 +1,280 @@ +from __future__ import annotations + +import json +import tempfile +from email import policy +from email.message import EmailMessage +from pathlib import Path +from typing import Any + +from sqlalchemy.orm import Session + +from app.db.models import Campaign, CampaignVersion +from app.mailer.campaign.loader import load_campaign_config +from app.mailer.campaign.validation import validate_campaign_config +from app.mailer.messages.builder import build_campaign_messages +from app.mailer.messages.models import MessageAddress, MessageDraft, MessageValidationStatus +from app.mailer.dev.mock_mailbox import ( + clear_records, + consume_fail_next_imap, + consume_fail_next_smtp, + get_failures, + list_records, + record_imap_append, + record_smtp_delivery, +) + + +class MockCampaignSendError(RuntimeError): + pass + + +def _message_address_payload(address: MessageAddress | None) -> dict[str, Any] | None: + if address is None: + return None + return {"email": address.email, "name": address.name} + + +def _message_addresses_payload(addresses: list[MessageAddress]) -> list[dict[str, Any]]: + return [{"email": item.email, "name": item.name} for item in addresses] + + +def _issue_payloads(message: MessageDraft) -> list[dict[str, Any]]: + return [issue.model_dump(mode="json") for issue in message.issues] + + +def _attachment_payloads(message: MessageDraft) -> list[dict[str, Any]]: + return [attachment.model_dump(mode="json") for attachment in message.attachments] + + +def _message_payload(message: MessageDraft) -> dict[str, Any]: + return { + "entry_index": message.entry_index, + "entry_id": message.entry_id, + "active": message.active, + "subject": message.subject, + "from": _message_address_payload(message.from_), + "to": _message_addresses_payload(message.to), + "cc": _message_addresses_payload(message.cc), + "bcc": _message_addresses_payload(message.bcc), + "reply_to": _message_addresses_payload(message.reply_to), + "build_status": str(message.build_status.value if hasattr(message.build_status, "value") else message.build_status), + "validation_status": message.validation_status.value, + "send_status": str(message.send_status.value if hasattr(message.send_status, "value") else message.send_status), + "imap_status": message.imap_status.value, + "attachment_count": message.attachment_count, + "attachments": _attachment_payloads(message), + "issues": _issue_payloads(message), + "eml_size_bytes": message.eml_size_bytes, + "queueable": message.is_queueable, + } + + +def _recipient_emails(message: MessageDraft) -> list[str]: + values = [item.email for item in message.to + message.cc + message.bcc if item.email] + return list(dict.fromkeys(values)) + + +def _envelope_from(message: MessageDraft, *, fallback: str = "mock-sender@mock.local") -> str: + if message.bounce_to: + return message.bounce_to[0].email + if message.from_ and message.from_.email: + return message.from_.email + return fallback + + +def _raw_message_bytes(message: EmailMessage) -> bytes: + return message.as_bytes(policy=policy.SMTP) + + +def _smtp_rejection_matches(recipients: list[str]) -> list[str]: + needle = (get_failures().get("smtp_reject_recipients_containing") or "").strip().lower() + if not needle: + return [] + return [recipient for recipient in recipients if needle in recipient.lower()] + + +def _can_mock_send( + message: MessageDraft, + *, + include_warnings: bool, + include_needs_review: bool, +) -> tuple[bool, str | None]: + if not message.active: + return False, "Recipient is inactive" + if str(message.build_status.value if hasattr(message.build_status, "value") else message.build_status) != "built": + return False, f"Message is not built ({message.build_status})" + if message.validation_status == MessageValidationStatus.READY: + return True, None + if message.validation_status == MessageValidationStatus.WARNING and include_warnings: + return True, None + if message.validation_status == MessageValidationStatus.NEEDS_REVIEW and include_needs_review: + return True, None + return False, f"Validation status is {message.validation_status.value}" + + +def run_mock_campaign_send( + session: Session, + *, + tenant_id: str, + campaign_id: str, + version_id: str | None = None, + send: bool = False, + include_warnings: bool = True, + include_needs_review: bool = False, + append_sent: bool = True, + clear_mailbox: bool = False, + check_files: bool = False, +) -> dict[str, Any]: + """Validate, build and optionally mock-send a version without mutating it. + + This is a dev/test route. It does not change campaign/version status, does + not queue real jobs and does not use the configured SMTP/IMAP servers. It + records mock SMTP deliveries and mock IMAP appends in the integrated mock + mailbox only when send=True. + """ + + campaign = session.query(Campaign).filter(Campaign.id == campaign_id, Campaign.tenant_id == tenant_id).one_or_none() + if not campaign: + raise MockCampaignSendError("Campaign not found or not accessible") + wanted_version_id = version_id or campaign.current_version_id + if not wanted_version_id: + raise MockCampaignSendError("Campaign has no current version") + version = session.get(CampaignVersion, wanted_version_id) + if not version or version.campaign_id != campaign.id: + raise MockCampaignSendError("Campaign version not found or not part of campaign") + + if clear_mailbox: + clear_records() + + with tempfile.TemporaryDirectory(prefix="multimailer-mock-send-") as temp_dir: + temp_path = Path(temp_dir) + campaign_path = temp_path / f"campaign-{version.id}.json" + campaign_path.write_text(json.dumps(version.raw_json, ensure_ascii=False, indent=2), encoding="utf-8") + config = load_campaign_config(campaign_path) + validation_report = validate_campaign_config(config, campaign_file=campaign_path, check_files=check_files) + build_result = build_campaign_messages(config, campaign_file=campaign_path, write_eml=False) + + send_results: list[dict[str, Any]] = [] + sent_count = 0 + failed_count = 0 + skipped_count = 0 + imap_appended_count = 0 + imap_failed_count = 0 + + for built in build_result.built_messages: + draft = built.draft + can_send, skip_reason = _can_mock_send(draft, include_warnings=include_warnings, include_needs_review=include_needs_review) + row: dict[str, Any] = { + "entry_index": draft.entry_index, + "entry_id": draft.entry_id, + "subject": draft.subject, + "validation_status": draft.validation_status.value, + "build_status": str(draft.build_status.value if hasattr(draft.build_status, "value") else draft.build_status), + "to": _message_addresses_payload(draft.to), + "attachments": _attachment_payloads(draft), + "issues": _issue_payloads(draft), + } + + if not can_send or built.mime is None: + skipped_count += 1 + row.update({"status": "skipped", "message": skip_reason or "Message has no MIME output"}) + send_results.append(row) + continue + + recipients = _recipient_emails(draft) + envelope_from = _envelope_from(draft) + if not recipients: + skipped_count += 1 + row.update({"status": "skipped", "message": "No envelope recipients"}) + send_results.append(row) + continue + + if not send: + row.update({"status": "ready", "message": f"Would send to {len(recipients)} recipient(s)", "envelope_from": envelope_from, "envelope_recipients": recipients}) + send_results.append(row) + continue + + try: + if consume_fail_next_smtp(): + raise MockCampaignSendError("Configured mock failure: next SMTP delivery fails") + rejected = _smtp_rejection_matches(recipients) + if rejected and len(rejected) == len(recipients): + raise MockCampaignSendError(f"Configured mock failure: all recipients rejected ({', '.join(rejected)})") + + accepted = [recipient for recipient in recipients if recipient not in rejected] + smtp_record = record_smtp_delivery(built.mime, envelope_from=envelope_from, envelope_recipients=accepted, smtp_host="mock.smtp.local") + sent_count += 1 + row.update({ + "status": "sent", + "message": f"Mock SMTP captured as {smtp_record.id}", + "smtp_message_id": smtp_record.id, + "envelope_from": envelope_from, + "envelope_recipients": accepted, + "refused_recipients": rejected, + }) + + if append_sent: + try: + if consume_fail_next_imap(): + raise MockCampaignSendError("Configured mock failure: next IMAP append fails") + folder = "Sent" + if config.delivery.imap_append_sent.folder and config.delivery.imap_append_sent.folder != "auto": + folder = config.delivery.imap_append_sent.folder + elif config.server.imap and config.server.imap.sent_folder and config.server.imap.sent_folder != "auto": + folder = config.server.imap.sent_folder + imap_record = record_imap_append(_raw_message_bytes(built.mime), folder=folder, imap_host="mock.imap.local") + imap_appended_count += 1 + row.update({"imap_status": "appended", "imap_message_id": imap_record.id, "imap_folder": folder}) + except Exception as exc: + imap_failed_count += 1 + row.update({"imap_status": "failed", "imap_error": str(exc)}) + + except Exception as exc: + failed_count += 1 + row.update({"status": "failed", "message": str(exc), "envelope_from": envelope_from, "envelope_recipients": recipients}) + + send_results.append(row) + + validation_json = validation_report.model_dump(mode="json") + validation_json.update({"ok": validation_report.ok, "error_count": validation_report.error_count, "warning_count": validation_report.warning_count}) + build_report = build_result.report + build_json = build_report.model_dump(mode="json") + build_json.update({ + "built_count": build_report.built_count, + "queueable_count": build_report.queueable_count, + "needs_review_count": build_report.needs_review_count, + "blocked_count": build_report.blocked_count, + "warning_count": build_report.warning_count, + "ready_count": build_report.ready_count, + "messages": [_message_payload(message) for message in build_report.messages], + }) + + attempted_count = sum(1 for row in send_results if row.get("status") in {"sent", "failed"}) + return { + "campaign_id": campaign.id, + "version_id": version.id, + "version_number": version.version_number, + "send_requested": send, + "include_warnings": include_warnings, + "include_needs_review": include_needs_review, + "append_sent": append_sent, + "steps": [ + {"key": "validate", "label": "Validate campaign JSON", "status": "ok" if validation_report.ok else "needs_review", "summary": validation_json}, + {"key": "build", "label": "Build messages", "status": "ok" if build_report.queueable_count else "needs_review", "summary": {"built": build_report.built_count, "queueable": build_report.queueable_count, "needs_review": build_report.needs_review_count, "blocked": build_report.blocked_count}}, + {"key": "send", "label": "Mock SMTP delivery", "status": "skipped" if not send else ("ok" if failed_count == 0 else "needs_review"), "summary": {"attempted": attempted_count, "sent": sent_count, "failed": failed_count, "skipped": skipped_count}}, + {"key": "imap", "label": "Mock IMAP Sent append", "status": "skipped" if not send or not append_sent else ("ok" if imap_failed_count == 0 else "needs_review"), "summary": {"appended": imap_appended_count, "failed": imap_failed_count}}, + ], + "validation": validation_json, + "build": build_json, + "send": { + "attempted_count": attempted_count, + "sent_count": sent_count, + "failed_count": failed_count, + "skipped_count": skipped_count, + "imap_appended_count": imap_appended_count, + "imap_failed_count": imap_failed_count, + "results": send_results, + }, + "mailbox": {"messages": list_records(limit=200)}, + } diff --git a/server/app/mailer/dev/mock_mailbox.py b/server/app/mailer/dev/mock_mailbox.py new file mode 100644 index 0000000..1182d58 --- /dev/null +++ b/server/app/mailer/dev/mock_mailbox.py @@ -0,0 +1,327 @@ +from __future__ import annotations + +import json +import re +import shutil +from dataclasses import dataclass +from datetime import datetime, timezone +from email import policy +from email.message import EmailMessage +from email.parser import BytesParser +from pathlib import Path +from typing import Any +from uuid import uuid4 + +from app.settings import settings + +MOCK_SMTP_HOSTS = {"mock", "mock.smtp", "mock.smtp.local", "mock-mail", "__mock_smtp__"} +MOCK_IMAP_HOSTS = {"mock", "mock.imap", "mock.imap.local", "mock-mail", "__mock_imap__"} +MOCK_IMAP_FOLDERS = [ + {"name": "INBOX", "flags": []}, + {"name": "Sent", "flags": ["\\Sent"]}, + {"name": "Drafts", "flags": ["\\Drafts"]}, + {"name": "Trash", "flags": ["\\Trash"]}, + {"name": "Archive", "flags": ["\\Archive"]}, +] + + +@dataclass(frozen=True, slots=True) +class MockDeliveryRecord: + id: str + kind: str + created_at: str + envelope_from: str | None + envelope_recipients: list[str] + subject: str | None + from_header: str | None + to_header: str | None + cc_header: str | None + bcc_header: str | None + message_id: str | None + size_bytes: int + body_preview: str | None + attachment_count: int + folder: str | None = None + smtp_host: str | None = None + imap_host: str | None = None + raw_filename: str | None = None + headers: dict[str, str] | None = None + attachments: list[dict[str, Any]] | None = None + + def as_dict(self, *, include_raw: bool = False) -> dict[str, Any]: + data = { + "id": self.id, + "kind": self.kind, + "created_at": self.created_at, + "envelope_from": self.envelope_from, + "envelope_recipients": self.envelope_recipients, + "subject": self.subject, + "from_header": self.from_header, + "to_header": self.to_header, + "cc_header": self.cc_header, + "bcc_header": self.bcc_header, + "message_id": self.message_id, + "size_bytes": self.size_bytes, + "body_preview": self.body_preview, + "attachment_count": self.attachment_count, + "folder": self.folder, + "smtp_host": self.smtp_host, + "imap_host": self.imap_host, + "raw_filename": self.raw_filename, + "headers": self.headers or {}, + "attachments": self.attachments or [], + } + if include_raw: + data["raw_eml"] = read_raw_message(self.id) + return data + + +def _base_dir() -> Path: + path = Path(settings.mock_mailbox_dir) + path.mkdir(parents=True, exist_ok=True) + (path / "messages").mkdir(parents=True, exist_ok=True) + return path + + +def _message_dir() -> Path: + return _base_dir() / "messages" + + +def _failure_path() -> Path: + return _base_dir() / "failures.json" + + +def normalize_mock_host(host: str | None) -> str: + return (host or "").strip().lower() + + +def is_mock_smtp_host(host: str | None) -> bool: + return normalize_mock_host(host) in MOCK_SMTP_HOSTS + + +def is_mock_imap_host(host: str | None) -> bool: + return normalize_mock_host(host) in MOCK_IMAP_HOSTS + + +def _now_iso() -> str: + return datetime.now(timezone.utc).isoformat() + + +def _message_to_bytes(message: EmailMessage | bytes) -> bytes: + if isinstance(message, bytes): + return message + return message.as_bytes(policy=policy.SMTP) + + +def _parse_message(raw: bytes) -> EmailMessage: + return BytesParser(policy=policy.default).parsebytes(raw) + + +def _header_text(message: EmailMessage, name: str) -> str | None: + value = message.get(name) + return str(value) if value is not None else None + + +def _body_preview(message: EmailMessage) -> str | None: + body = None + try: + if message.is_multipart(): + body = message.get_body(preferencelist=("plain", "html")) + else: + body = message + if body is None: + return None + content = body.get_content() + except Exception: + return None + text = re.sub(r"\s+", " ", str(content)).strip() + if not text: + return None + return text[:600] + + +def _attachment_summaries(message: EmailMessage) -> list[dict[str, Any]]: + attachments: list[dict[str, Any]] = [] + for part in message.iter_attachments(): + payload = part.get_payload(decode=True) or b"" + attachments.append( + { + "filename": part.get_filename(), + "content_type": part.get_content_type(), + "size_bytes": len(payload), + } + ) + return attachments + + +def _headers(message: EmailMessage) -> dict[str, str]: + return {str(key): str(value) for key, value in message.items()} + + +def _record_from_raw( + raw: bytes, + *, + kind: str, + envelope_from: str | None = None, + envelope_recipients: list[str] | None = None, + folder: str | None = None, + smtp_host: str | None = None, + imap_host: str | None = None, +) -> MockDeliveryRecord: + message_id = uuid4().hex + raw_filename = f"{message_id}.eml" + json_filename = f"{message_id}.json" + message = _parse_message(raw) + attachments = _attachment_summaries(message) + record = MockDeliveryRecord( + id=message_id, + kind=kind, + created_at=_now_iso(), + envelope_from=envelope_from, + envelope_recipients=list(envelope_recipients or []), + subject=_header_text(message, "Subject"), + from_header=_header_text(message, "From"), + to_header=_header_text(message, "To"), + cc_header=_header_text(message, "Cc"), + bcc_header=_header_text(message, "Bcc"), + message_id=_header_text(message, "Message-ID"), + size_bytes=len(raw), + body_preview=_body_preview(message), + attachment_count=len(attachments), + folder=folder, + smtp_host=smtp_host, + imap_host=imap_host, + raw_filename=raw_filename, + headers=_headers(message), + attachments=attachments, + ) + message_dir = _message_dir() + (message_dir / raw_filename).write_bytes(raw) + (message_dir / json_filename).write_text(json.dumps(record.as_dict(), indent=2, ensure_ascii=False), encoding="utf-8") + return record + + +def record_smtp_delivery( + message: EmailMessage, + *, + envelope_from: str, + envelope_recipients: list[str], + smtp_host: str | None = None, +) -> MockDeliveryRecord: + return _record_from_raw( + _message_to_bytes(message), + kind="smtp", + envelope_from=envelope_from, + envelope_recipients=envelope_recipients, + smtp_host=smtp_host, + ) + + +def record_imap_append( + message_bytes: bytes, + *, + folder: str, + imap_host: str | None = None, +) -> MockDeliveryRecord: + return _record_from_raw(message_bytes, kind="imap_append", folder=folder, imap_host=imap_host) + + +def _load_record(record_id: str) -> dict[str, Any] | None: + path = _message_dir() / f"{record_id}.json" + if not path.exists(): + return None + try: + return json.loads(path.read_text(encoding="utf-8")) + except json.JSONDecodeError: + return None + + +def list_records(*, kind: str | None = None, limit: int = 100) -> list[dict[str, Any]]: + records: list[dict[str, Any]] = [] + for path in _message_dir().glob("*.json"): + try: + record = json.loads(path.read_text(encoding="utf-8")) + except json.JSONDecodeError: + continue + if kind and record.get("kind") != kind: + continue + records.append(record) + records.sort(key=lambda item: str(item.get("created_at") or ""), reverse=True) + return records[: max(1, min(limit, 500))] + + +def get_record(record_id: str, *, include_raw: bool = True) -> dict[str, Any] | None: + record = _load_record(record_id) + if record and include_raw: + record["raw_eml"] = read_raw_message(record_id) + return record + + +def read_raw_message(record_id: str) -> str | None: + record = _load_record(record_id) + if not record: + return None + raw_filename = record.get("raw_filename") + if not raw_filename: + return None + raw_path = _message_dir() / str(raw_filename) + if not raw_path.exists(): + return None + return raw_path.read_text(encoding="utf-8", errors="replace") + + +def clear_records() -> int: + message_dir = _message_dir() + count = len(list(message_dir.glob("*.json"))) + if message_dir.exists(): + shutil.rmtree(message_dir) + message_dir.mkdir(parents=True, exist_ok=True) + return count + + +def get_failures() -> dict[str, Any]: + path = _failure_path() + if not path.exists(): + return { + "fail_next_smtp": False, + "fail_next_imap": False, + "smtp_reject_recipients_containing": None, + } + try: + data = json.loads(path.read_text(encoding="utf-8")) + except json.JSONDecodeError: + data = {} + return { + "fail_next_smtp": bool(data.get("fail_next_smtp")), + "fail_next_imap": bool(data.get("fail_next_imap")), + "smtp_reject_recipients_containing": data.get("smtp_reject_recipients_containing") or None, + } + + +def set_failures(*, fail_next_smtp: bool | None = None, fail_next_imap: bool | None = None, smtp_reject_recipients_containing: str | None = None) -> dict[str, Any]: + current = get_failures() + if fail_next_smtp is not None: + current["fail_next_smtp"] = fail_next_smtp + if fail_next_imap is not None: + current["fail_next_imap"] = fail_next_imap + current["smtp_reject_recipients_containing"] = smtp_reject_recipients_containing or None + _failure_path().write_text(json.dumps(current, indent=2), encoding="utf-8") + return current + + +def consume_fail_next_smtp() -> bool: + current = get_failures() + if current.get("fail_next_smtp"): + current["fail_next_smtp"] = False + _failure_path().write_text(json.dumps(current, indent=2), encoding="utf-8") + return True + return False + + +def consume_fail_next_imap() -> bool: + current = get_failures() + if current.get("fail_next_imap"): + current["fail_next_imap"] = False + _failure_path().write_text(json.dumps(current, indent=2), encoding="utf-8") + return True + return False diff --git a/server/app/mailer/messages/builder.py b/server/app/mailer/messages/builder.py index 3f088c7..13fd6b3 100644 --- a/server/app/mailer/messages/builder.py +++ b/server/app/mailer/messages/builder.py @@ -129,9 +129,14 @@ def _recipient_values(entry: EntryConfig) -> dict[str, str]: def _template_values(config: CampaignConfig, entry: EntryConfig) -> dict[str, Any]: values: dict[str, Any] = {} + for field in config.fields: + values.setdefault(field.name, "") + values.setdefault(f"global::{field.name}", "") + values.setdefault(f"local::{field.name}", "") for key, value in config.global_values.items(): values[f"global::{key}"] = value for key, value in effective_entry_field_values(config, entry).items(): + values[key] = value values[f"local::{key}"] = value if entry.id: values["local::id"] = entry.id @@ -552,6 +557,62 @@ def build_entry_message( return BuiltMessage(draft=draft, mime=message) + +def _unsent_attachment_issues( + *, + config: CampaignConfig, + campaign_file: str | Path, + built_messages: list[BuiltMessage], +) -> list[MessageIssue]: + behavior = config.validation_policy.unsent_attachment_files.value + if behavior == Behavior.CONTINUE.value: + return [] + + matched_files = { + Path(match).resolve() + for built in built_messages + for attachment in built.draft.attachments + for match in attachment.matches + } + + issues: list[MessageIssue] = [] + for base_path in config.attachments.base_paths: + if not base_path.unsent_warning: + continue + directory = _resolve(campaign_file, base_path.path) + if not directory.exists() or not directory.is_dir(): + continue + all_files = sorted(path.resolve() for path in directory.rglob("*") if path.is_file()) + unsent = [path for path in all_files if path not in matched_files] + if not unsent: + continue + shown = ", ".join(str(path.relative_to(directory)) for path in unsent[:10]) + if len(unsent) > 10: + shown += f", … (+{len(unsent) - 10} more)" + issues.append( + _issue_from_behavior( + code="unsent_attachment_files", + message=f"{len(unsent)} file(s) in attachment source {base_path.name!r} are not used by any message: {shown}", + behavior=behavior, + source=f"attachments:{base_path.name}", + ) + ) + return issues + + +def _apply_campaign_level_issues(built_messages: list[BuiltMessage], issues: list[MessageIssue]) -> None: + if not issues: + return + for built in built_messages: + if not built.draft.active: + continue + built.draft.issues.extend(issues) + status = built.draft.validation_status + for issue in issues: + if issue.behavior: + status = _apply_behavior(status, issue.behavior) + built.draft.validation_status = status + def build_campaign_messages( config: CampaignConfig, *, @@ -577,6 +638,10 @@ def build_campaign_messages( ) for index, entry in enumerate(entries, start=1) ] + _apply_campaign_level_issues( + built_messages, + _unsent_attachment_issues(config=config, campaign_file=campaign_path, built_messages=built_messages), + ) report = CampaignBuildReport( campaign_id=config.campaign.id, diff --git a/server/app/mailer/persistence/campaigns.py b/server/app/mailer/persistence/campaigns.py index 37dcb47..073d5cf 100644 --- a/server/app/mailer/persistence/campaigns.py +++ b/server/app/mailer/persistence/campaigns.py @@ -25,6 +25,7 @@ from app.mailer.campaign.loader import load_campaign_config from app.mailer.campaign.validation import Severity, validate_campaign_config from app.mailer.messages.builder import build_campaign_messages from app.mailer.messages.models import MessageDraft +from app.storage.services import record_campaign_attachment_uses_for_job RUNTIME_DIR = Path(__file__).resolve().parents[3] / "runtime" CAMPAIGN_SNAPSHOT_DIR = RUNTIME_DIR / "campaign_snapshots" @@ -326,6 +327,7 @@ def build_campaign_version( ) session.add(job) session.flush() + record_campaign_attachment_uses_for_job(session, job, stage="built") for issue in built.draft.issues: session.add( CampaignIssue( diff --git a/server/app/mailer/persistence/versions.py b/server/app/mailer/persistence/versions.py index b464fc9..0c1c315 100644 --- a/server/app/mailer/persistence/versions.py +++ b/server/app/mailer/persistence/versions.py @@ -100,6 +100,7 @@ def minimal_campaign_json(*, external_id: str, name: str, description: str | Non "name": "Campaign files", "path": ".", "allow_individual": True, + "unsent_warning": False, } ], "allow_individual": True, @@ -126,6 +127,7 @@ def minimal_campaign_json(*, external_id: str, name: str, description: str | Non "missing_required_attachment": "ask", "missing_optional_attachment": "warn", "ambiguous_attachment_match": "ask", + "unsent_attachment_files": "warn", "ignore_empty_fields": False, "missing_email": "block", "template_error": "block", diff --git a/server/app/mailer/schema/campaign.schema.json b/server/app/mailer/schema/campaign.schema.json index fa52280..995b56f 100644 --- a/server/app/mailer/schema/campaign.schema.json +++ b/server/app/mailer/schema/campaign.schema.json @@ -494,11 +494,24 @@ "warn" ], "default": "drop" + }, + "unsent_attachment_files": { + "type": "string", + "enum": [ + "block", + "ask", + "drop", + "continue", + "warn" + ], + "default": "warn", + "description": "Behavior when a base path with unsent_warning contains files that are not attached to any message." } }, "additionalProperties": false, "default": { - "ignore_empty_fields": false + "ignore_empty_fields": false, + "unsent_attachment_files": "warn" } }, "delivery": { @@ -714,6 +727,13 @@ "default": { "enabled": false } + }, + "type": { + "description": "Legacy UI helper; ignored by backend. Direct files are represented as plain file_filter patterns.", + "type": [ + "string", + "null" + ] } }, "additionalProperties": false @@ -907,6 +927,11 @@ "null" ], "description": "Legacy UI compatibility value. Ignored by the backend." + }, + "unsent_warning": { + "type": "boolean", + "default": false, + "description": "Warn according to validation_policy.unsent_attachment_files if files in this source are not attached to any built message." } }, "additionalProperties": false diff --git a/server/app/mailer/sending/imap.py b/server/app/mailer/sending/imap.py index a5aa251..1820ee0 100644 --- a/server/app/mailer/sending/imap.py +++ b/server/app/mailer/sending/imap.py @@ -8,6 +8,12 @@ import time from dataclasses import dataclass from app.mailer.campaign.models import ImapConfig, TransportSecurity +from app.mailer.dev.mock_mailbox import ( + MOCK_IMAP_FOLDERS, + consume_fail_next_imap, + is_mock_imap_host, + record_imap_append, +) class ImapConfigurationError(ValueError): @@ -210,6 +216,14 @@ def test_imap_login(*, imap_config: ImapConfig) -> ImapLoginTestResult: """ host, port = _require_imap_config(imap_config) + if is_mock_imap_host(imap_config.host): + return ImapLoginTestResult( + host=host, + port=port, + security=imap_config.security.value, + authenticated=bool(imap_config.username and imap_config.password), + ) + client = _open_imap(imap_config) try: return ImapLoginTestResult( @@ -229,6 +243,16 @@ def list_imap_folders(*, imap_config: ImapConfig) -> ImapFolderListResult: """Return folders visible through IMAP LIST and the best sent-folder guess.""" host, port = _require_imap_config(imap_config) + if is_mock_imap_host(imap_config.host): + folders = [ImapMailboxInfo(name=str(item["name"]), flags=list(item.get("flags") or [])) for item in MOCK_IMAP_FOLDERS] + return ImapFolderListResult( + host=host, + port=port, + security=imap_config.security.value, + folders=folders, + detected_sent_folder="Sent", + ) + client = _open_imap(imap_config) try: typ, data = client.list() @@ -272,6 +296,20 @@ def append_message_to_sent( """ host, port = _require_imap_config(imap_config) + if is_mock_imap_host(imap_config.host): + if consume_fail_next_imap(): + raise ImapAppendError("Mock IMAP configured to fail the next append", temporary=False) + target_folder = folder or (imap_config.sent_folder if imap_config.sent_folder and imap_config.sent_folder != "auto" else "Sent") + record = record_imap_append(message_bytes, folder=target_folder, imap_host=imap_config.host) + return ImapAppendResult( + host=host, + port=port, + security=imap_config.security.value, + folder=target_folder, + bytes_appended=len(message_bytes), + response=f"mock append stored as {record.id}", + ) + client: imaplib.IMAP4 | None = None try: client = _open_imap(imap_config) diff --git a/server/app/mailer/sending/jobs.py b/server/app/mailer/sending/jobs.py index e3ac4cd..78fc13a 100644 --- a/server/app/mailer/sending/jobs.py +++ b/server/app/mailer/sending/jobs.py @@ -30,6 +30,7 @@ from app.mailer.persistence.campaigns import _write_campaign_snapshot from app.mailer.sending.rate_limit import wait_for_rate_limit from app.mailer.sending.smtp import SmtpConfigurationError, SmtpSendError, send_email_message from app.mailer.sending.imap import ImapAppendError, ImapConfigurationError, append_message_to_sent +from app.storage.services import mark_job_attachment_uses_sent class QueueingError(RuntimeError): @@ -591,6 +592,7 @@ def send_campaign_job(session: Session, *, job_id: str, dry_run: bool = False, u else: job.imap_status = JobImapStatus.NOT_REQUESTED.value job.last_error = None + mark_job_attachment_uses_sent(session, job) session.add(attempt) session.add(job) _update_campaign_after_job(session, job.campaign_id, job.campaign_version_id) @@ -702,6 +704,7 @@ def append_sent_for_job(session: Session, *, job_id: str, dry_run: bool = False) attempt.folder = result.folder job.imap_status = JobImapStatus.APPENDED.value job.last_error = None + mark_job_attachment_uses_sent(session, job) session.add(attempt) session.add(job) session.commit() diff --git a/server/app/mailer/sending/smtp.py b/server/app/mailer/sending/smtp.py index 1ea498e..854710b 100644 --- a/server/app/mailer/sending/smtp.py +++ b/server/app/mailer/sending/smtp.py @@ -8,6 +8,12 @@ from email.message import EmailMessage from email.utils import formataddr from app.mailer.campaign.models import SmtpConfig, TransportSecurity +from app.mailer.dev.mock_mailbox import ( + consume_fail_next_smtp, + get_failures, + is_mock_smtp_host, + record_smtp_delivery, +) class SmtpConfigurationError(ValueError): @@ -98,6 +104,15 @@ def test_smtp_login(*, smtp_config: SmtpConfig) -> SmtpLoginTestResult: """ host, port = _require_smtp_config(smtp_config) + if is_mock_smtp_host(smtp_config.host): + host, port = _require_smtp_config(smtp_config) + return SmtpLoginTestResult( + host=host, + port=port, + security=smtp_config.security.value, + authenticated=bool(smtp_config.username and smtp_config.password), + ) + smtp = _open_smtp(smtp_config) try: return SmtpLoginTestResult( @@ -165,6 +180,37 @@ def send_email_message( if not envelope_recipients: raise SmtpConfigurationError("at least one SMTP envelope recipient is required") + if is_mock_smtp_host(smtp_config.host): + if consume_fail_next_smtp(): + raise SmtpSendError("Mock SMTP configured to fail the next send") + failures = get_failures() + reject_text = str(failures.get("smtp_reject_recipients_containing") or "").strip().lower() + refused: dict[str, tuple[int, bytes]] = {} + accepted = list(envelope_recipients) + if reject_text: + refused = { + recipient: (550, b"mock recipient rejected") + for recipient in envelope_recipients + if reject_text in recipient.lower() + } + accepted = [recipient for recipient in envelope_recipients if recipient not in refused] + if not accepted: + raise SmtpSendError(f"all mock SMTP recipients were refused: {_decode_refused(refused)}") + record_smtp_delivery( + message, + envelope_from=envelope_from, + envelope_recipients=accepted, + smtp_host=smtp_config.host, + ) + return SmtpSendResult( + host=host, + port=port, + security=smtp_config.security.value, + envelope_from=envelope_from, + envelope_recipients=list(envelope_recipients), + refused_recipients=_decode_refused(refused), + ) + try: with _open_smtp(smtp_config) as smtp: refused = smtp.send_message( diff --git a/server/app/main.py b/server/app/main.py index 9365731..dcf43da 100644 --- a/server/app/main.py +++ b/server/app/main.py @@ -40,8 +40,10 @@ def health(): "env": settings.app_env, "api": {"version": "v1", "auth": "api-key-or-session"}, "storage": { - "endpoint": settings.s3_endpoint_url, - "bucket": settings.s3_bucket, - "region": settings.s3_region, + "backend": settings.file_storage_backend, + "local_root": settings.file_storage_local_root if settings.file_storage_backend == "local" else None, + "endpoint": settings.file_storage_s3_endpoint_url or settings.s3_endpoint_url, + "bucket": settings.file_storage_s3_bucket or settings.s3_bucket, + "region": settings.file_storage_s3_region or settings.s3_region, }, } diff --git a/server/app/settings.py b/server/app/settings.py index e0ef349..a556b12 100644 --- a/server/app/settings.py +++ b/server/app/settings.py @@ -19,8 +19,19 @@ class Settings(BaseSettings): s3_secret_access_key: str = Field(default="multimailer-dev-secret-change-me", alias="S3_SECRET_ACCESS_KEY") s3_bucket: str = Field(default="attachments", alias="S3_BUCKET") + # Managed file storage. Development defaults to local filesystem storage; + # production can switch to Garage/S3 without changing API contracts. + file_storage_backend: str = Field(default="local", alias="FILE_STORAGE_BACKEND") + file_storage_local_root: str = Field(default="runtime/files", alias="FILE_STORAGE_LOCAL_ROOT") + file_storage_s3_endpoint_url: str | None = Field(default=None, alias="FILE_STORAGE_S3_ENDPOINT_URL") + file_storage_s3_region: str | None = Field(default=None, alias="FILE_STORAGE_S3_REGION") + file_storage_s3_access_key_id: str | None = Field(default=None, alias="FILE_STORAGE_S3_ACCESS_KEY_ID") + file_storage_s3_secret_access_key: str | None = Field(default=None, alias="FILE_STORAGE_S3_SECRET_ACCESS_KEY") + file_storage_s3_bucket: str | None = Field(default="files", alias="FILE_STORAGE_S3_BUCKET") + master_key_b64: str | None = Field(default=None, alias="MASTER_KEY_B64") celery_queues: str = Field(default="send_email,append_sent,default", alias="CELERY_QUEUES") + mock_mailbox_dir: str = Field(default="runtime/mock-mailbox", alias="MOCK_MAILBOX_DIR") # Development bootstrap only. Do not use this in production. dev_bootstrap_api_key: str | None = Field(default="dev-multimailer-api-key", alias="DEV_BOOTSTRAP_API_KEY") diff --git a/server/app/storage/__init__.py b/server/app/storage/__init__.py new file mode 100644 index 0000000..784797a --- /dev/null +++ b/server/app/storage/__init__.py @@ -0,0 +1 @@ +"""Managed file storage services for Multi Seal Mail.""" diff --git a/server/app/storage/backends.py b/server/app/storage/backends.py new file mode 100644 index 0000000..35a1769 --- /dev/null +++ b/server/app/storage/backends.py @@ -0,0 +1,116 @@ +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +from typing import Protocol + +import boto3 + +from app.settings import settings + + +class StorageBackendError(RuntimeError): + pass + + +class StorageBackend(Protocol): + name: str + + def put_bytes(self, key: str, data: bytes, *, content_type: str | None = None) -> None: ... + def get_bytes(self, key: str) -> bytes: ... + def delete(self, key: str) -> None: ... + def exists(self, key: str) -> bool: ... + + +@dataclass(slots=True) +class LocalFilesystemStorageBackend: + root: Path + name: str = "local" + + def __post_init__(self) -> None: + self.root = self.root.expanduser().resolve() + self.root.mkdir(parents=True, exist_ok=True) + + def _path(self, key: str) -> Path: + path = (self.root / key).resolve() + if not path.is_relative_to(self.root): + raise StorageBackendError("Storage key escapes local storage root") + return path + + def put_bytes(self, key: str, data: bytes, *, content_type: str | None = None) -> None: + path = self._path(key) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(data) + + def get_bytes(self, key: str) -> bytes: + path = self._path(key) + if not path.exists() or not path.is_file(): + raise StorageBackendError("Stored object does not exist") + return path.read_bytes() + + def delete(self, key: str) -> None: + path = self._path(key) + if path.exists() and path.is_file(): + path.unlink() + + def exists(self, key: str) -> bool: + path = self._path(key) + return path.exists() and path.is_file() + + +@dataclass(slots=True) +class S3StorageBackend: + bucket: str + endpoint_url: str + region_name: str + access_key_id: str + secret_access_key: str + name: str = "s3" + + @property + def client(self): + return boto3.client( + "s3", + endpoint_url=self.endpoint_url, + region_name=self.region_name, + aws_access_key_id=self.access_key_id, + aws_secret_access_key=self.secret_access_key, + ) + + def put_bytes(self, key: str, data: bytes, *, content_type: str | None = None) -> None: + kwargs = {"Bucket": self.bucket, "Key": key, "Body": data} + if content_type: + kwargs["ContentType"] = content_type + self.client.put_object(**kwargs) + + def get_bytes(self, key: str) -> bytes: + try: + obj = self.client.get_object(Bucket=self.bucket, Key=key) + return obj["Body"].read() + except Exception as exc: # pragma: no cover - depends on S3 backend + raise StorageBackendError(str(exc)) from exc + + def delete(self, key: str) -> None: + self.client.delete_object(Bucket=self.bucket, Key=key) + + def exists(self, key: str) -> bool: + try: + self.client.head_object(Bucket=self.bucket, Key=key) + return True + except Exception: + return False + + +def get_storage_backend() -> StorageBackend: + backend = settings.file_storage_backend.lower().strip() + if backend in {"local", "filesystem", "fs"}: + return LocalFilesystemStorageBackend(Path(settings.file_storage_local_root)) + if backend in {"s3", "garage"}: + return S3StorageBackend( + bucket=settings.file_storage_s3_bucket or settings.s3_bucket, + endpoint_url=settings.file_storage_s3_endpoint_url or settings.s3_endpoint_url, + region_name=settings.file_storage_s3_region or settings.s3_region, + access_key_id=settings.file_storage_s3_access_key_id or settings.s3_access_key_id, + secret_access_key=settings.file_storage_s3_secret_access_key or settings.s3_secret_access_key, + ) + raise StorageBackendError(f"Unsupported file storage backend: {settings.file_storage_backend}") diff --git a/server/app/storage/paths.py b/server/app/storage/paths.py new file mode 100644 index 0000000..0c9c4e9 --- /dev/null +++ b/server/app/storage/paths.py @@ -0,0 +1,73 @@ +from __future__ import annotations + +import re +from pathlib import PurePosixPath +from uuid import uuid4 + +_SAFE_NAME_RE = re.compile(r"[^A-Za-z0-9_.@ -]+") + + +class UnsafeFilePathError(ValueError): + pass + + +def normalize_logical_path(path: str | None, *, fallback_filename: str | None = None) -> str: + """Return a safe tenant-relative logical path using POSIX separators. + + The logical path is metadata, not a filesystem path. It never starts with a + slash and cannot contain path traversal components. It is used for browsing, + wildcard matching and attachment rules. + """ + + raw = (path or "").replace("\\", "/").strip() + if not raw and fallback_filename: + raw = fallback_filename + if not raw: + raise UnsafeFilePathError("File path is empty") + if raw.startswith("/"): + raw = raw.lstrip("/") + parts: list[str] = [] + for part in raw.split("/"): + clean = part.strip() + if not clean or clean == ".": + continue + if clean == "..": + raise UnsafeFilePathError("Path traversal is not allowed") + parts.append(clean) + if not parts: + raise UnsafeFilePathError("File path is empty") + return "/".join(parts) + + +def normalize_folder(path: str | None) -> str: + raw = (path or "").replace("\\", "/").strip().strip("/") + if not raw: + return "" + normalized = normalize_logical_path(raw) + return "" if normalized == "." else normalized + + +def filename_from_path(path: str) -> str: + name = PurePosixPath(path).name + if not name or name in {".", ".."}: + raise UnsafeFilePathError("Invalid filename") + return name + + +def join_folder_filename(folder: str | None, filename: str) -> str: + safe_name = sanitize_filename(filename) + safe_folder = normalize_folder(folder) + return f"{safe_folder}/{safe_name}" if safe_folder else safe_name + + +def sanitize_filename(filename: str | None) -> str: + raw = (filename or "file").replace("\\", "/").split("/")[-1].strip() + raw = raw.strip(".") or "file" + safe = _SAFE_NAME_RE.sub("_", raw) + safe = re.sub(r"\s+", " ", safe).strip() + return safe or f"file-{uuid4().hex}" + + +def safe_storage_component(value: str | None, fallback: str = "file") -> str: + safe = sanitize_filename(value or fallback) + return safe.replace(" ", "_")[:180] diff --git a/server/app/storage/services.py b/server/app/storage/services.py new file mode 100644 index 0000000..9ac9b64 --- /dev/null +++ b/server/app/storage/services.py @@ -0,0 +1,750 @@ +from __future__ import annotations + +import hashlib +import mimetypes +import re +import zipfile +from dataclasses import dataclass +from datetime import datetime, timezone +from io import BytesIO +from pathlib import PurePosixPath +from typing import Any, Iterable +from uuid import uuid4 + +from sqlalchemy import or_ +from sqlalchemy.orm import Session + +from app.db.models import ( + Campaign, + CampaignAttachmentUse, + CampaignJob, + FileAsset, + FileBlob, + FileFolder, + FileShare, + FileVersion, + Group, + UserGroupMembership, +) +from app.settings import settings +from app.storage.backends import get_storage_backend +from app.storage.paths import filename_from_path, join_folder_filename, normalize_folder, normalize_logical_path, safe_storage_component + + +class FileStorageError(RuntimeError): + pass + + +@dataclass(slots=True) +class UploadedStoredFile: + asset: FileAsset + version: FileVersion + blob: FileBlob + + +@dataclass(slots=True) +class ResolvedPattern: + pattern: str + matches: list[FileAsset] + + +def utcnow() -> datetime: + return datetime.now(timezone.utc) + + +def user_group_ids(session: Session, *, tenant_id: str, user_id: str, include_admin_groups: bool = False) -> list[str]: + if include_admin_groups: + return [row.id for row in session.query(Group).filter(Group.tenant_id == tenant_id).order_by(Group.name.asc()).all()] + return [ + row.group_id + for row in session.query(UserGroupMembership) + .filter(UserGroupMembership.tenant_id == tenant_id, UserGroupMembership.user_id == user_id) + .all() + ] + + +def ensure_group_access(session: Session, *, tenant_id: str, group_id: str, user_id: str, is_admin: bool = False) -> None: + group = session.get(Group, group_id) + if not group or group.tenant_id != tenant_id: + raise FileStorageError("Group not found") + if is_admin: + return + membership = ( + session.query(UserGroupMembership) + .filter(UserGroupMembership.tenant_id == tenant_id, UserGroupMembership.user_id == user_id, UserGroupMembership.group_id == group_id) + .one_or_none() + ) + if membership is None: + raise FileStorageError("No access to this group file space") + + + + +def _owner_filter(query, owner_type: str, owner_id: str): + if owner_type == "user": + return query.filter(FileFolder.owner_user_id == owner_id) + if owner_type == "group": + return query.filter(FileFolder.owner_group_id == owner_id) + raise FileStorageError("Unsupported owner type") + + +def ensure_owner_access(session: Session, *, tenant_id: str, owner_type: str, owner_id: str, user_id: str, is_admin: bool = False) -> None: + owner_type = owner_type.lower().strip() + if owner_type == "user": + if owner_id != user_id and not is_admin: + raise FileStorageError("No access to this user file space") + return + if owner_type == "group": + ensure_group_access(session, tenant_id=tenant_id, group_id=owner_id, user_id=user_id, is_admin=is_admin) + return + raise FileStorageError("Files must be owned by a user or group") + + +def create_folder( + session: Session, + *, + tenant_id: str, + owner_type: str, + owner_id: str, + user_id: str, + path: str, + is_admin: bool = False, +) -> FileFolder: + owner_type = owner_type.lower().strip() + ensure_owner_access(session, tenant_id=tenant_id, owner_type=owner_type, owner_id=owner_id, user_id=user_id, is_admin=is_admin) + normalized = normalize_folder(path) + if not normalized: + raise FileStorageError("Folder path is required") + query = session.query(FileFolder).filter(FileFolder.tenant_id == tenant_id, FileFolder.owner_type == owner_type, FileFolder.path == normalized) + query = _owner_filter(query, owner_type, owner_id) + existing = query.order_by(FileFolder.deleted_at.asc()).first() + if existing: + if existing.deleted_at is not None: + existing.deleted_at = None + session.add(existing) + return existing + folder = FileFolder( + tenant_id=tenant_id, + owner_type=owner_type, + owner_user_id=owner_id if owner_type == "user" else None, + owner_group_id=owner_id if owner_type == "group" else None, + path=normalized, + created_by_user_id=user_id, + metadata_={}, + ) + session.add(folder) + session.flush() + return folder + + +def list_folders_for_user( + session: Session, + *, + tenant_id: str, + user_id: str, + owner_type: str, + owner_id: str, + include_deleted: bool = False, + is_admin: bool = False, +) -> list[FileFolder]: + owner_type = owner_type.lower().strip() + ensure_owner_access(session, tenant_id=tenant_id, owner_type=owner_type, owner_id=owner_id, user_id=user_id, is_admin=is_admin) + query = session.query(FileFolder).filter(FileFolder.tenant_id == tenant_id, FileFolder.owner_type == owner_type) + query = _owner_filter(query, owner_type, owner_id) + if not include_deleted: + query = query.filter(FileFolder.deleted_at.is_(None)) + return query.order_by(FileFolder.path.asc()).all() + + +def soft_delete_folder( + session: Session, + *, + tenant_id: str, + owner_type: str, + owner_id: str, + user_id: str, + path: str, + recursive: bool = True, + is_admin: bool = False, +) -> tuple[int, int]: + owner_type = owner_type.lower().strip() + ensure_owner_access(session, tenant_id=tenant_id, owner_type=owner_type, owner_id=owner_id, user_id=user_id, is_admin=is_admin) + normalized = normalize_folder(path) + if not normalized: + raise FileStorageError("Folder path is required") + prefix = f"{normalized}/" + now = utcnow() + + folder_query = session.query(FileFolder).filter(FileFolder.tenant_id == tenant_id, FileFolder.owner_type == owner_type, FileFolder.deleted_at.is_(None)) + folder_query = _owner_filter(folder_query, owner_type, owner_id) + if recursive: + folder_query = folder_query.filter(or_(FileFolder.path == normalized, FileFolder.path.like(f"{prefix}%"))) + else: + child_exists = folder_query.filter(FileFolder.path.like(f"{prefix}%")).first() is not None + file_exists = _asset_query_for_owner(session, tenant_id=tenant_id, owner_type=owner_type, owner_id=owner_id).filter(FileAsset.display_path.like(f"{prefix}%")).first() is not None + if child_exists or file_exists: + raise FileStorageError("Folder is not empty") + folder_query = folder_query.filter(FileFolder.path == normalized) + + folders = folder_query.all() + for folder in folders: + folder.deleted_at = now + session.add(folder) + + file_query = _asset_query_for_owner(session, tenant_id=tenant_id, owner_type=owner_type, owner_id=owner_id).filter(FileAsset.deleted_at.is_(None), FileAsset.display_path.like(f"{prefix}%")) + assets = file_query.all() if recursive else [] + for asset in assets: + asset.deleted_at = now + session.add(asset) + + return len(folders), len(assets) + + +def _asset_query_for_owner(session: Session, *, tenant_id: str, owner_type: str, owner_id: str): + query = session.query(FileAsset).filter(FileAsset.tenant_id == tenant_id, FileAsset.owner_type == owner_type) + if owner_type == "user": + return query.filter(FileAsset.owner_user_id == owner_id) + if owner_type == "group": + return query.filter(FileAsset.owner_group_id == owner_id) + raise FileStorageError("Unsupported owner type") + +def _storage_bucket_name() -> str: + return settings.file_storage_s3_bucket or settings.s3_bucket + + +def _storage_backend_name() -> str: + return settings.file_storage_backend.lower().strip() + + +def _storage_key(*, tenant_id: str, checksum: str, filename: str) -> str: + return f"tenants/{tenant_id}/files/{checksum[:2]}/{uuid4().hex}-{safe_storage_component(filename)}" + + +def _get_or_create_blob( + session: Session, + *, + tenant_id: str, + data: bytes, + filename: str, + content_type: str | None, +) -> FileBlob: + checksum = hashlib.sha256(data).hexdigest() + size = len(data) + blob = ( + session.query(FileBlob) + .filter(FileBlob.tenant_id == tenant_id, FileBlob.checksum_sha256 == checksum, FileBlob.size_bytes == size) + .one_or_none() + ) + if blob: + blob.ref_count += 1 + session.add(blob) + return blob + + storage_key = _storage_key(tenant_id=tenant_id, checksum=checksum, filename=filename) + backend = get_storage_backend() + backend.put_bytes(storage_key, data, content_type=content_type) + blob = FileBlob( + tenant_id=tenant_id, + storage_backend=_storage_backend_name(), + storage_bucket=_storage_bucket_name(), + storage_key=storage_key, + checksum_sha256=checksum, + size_bytes=size, + content_type=content_type, + ref_count=1, + ) + session.add(blob) + session.flush() + return blob + + +def create_file_asset( + session: Session, + *, + tenant_id: str, + owner_type: str, + owner_id: str, + user_id: str, + filename: str, + data: bytes, + folder: str | None = None, + display_path: str | None = None, + content_type: str | None = None, + description: str | None = None, + metadata: dict[str, Any] | None = None, + campaign_id: str | None = None, + is_admin: bool = False, +) -> UploadedStoredFile: + owner_type = owner_type.lower().strip() + ensure_owner_access(session, tenant_id=tenant_id, owner_type=owner_type, owner_id=owner_id, user_id=user_id, is_admin=is_admin) + + safe_filename = filename_from_path(normalize_logical_path(filename, fallback_filename="file")) + logical_path = normalize_logical_path(display_path) if display_path else join_folder_filename(folder, safe_filename) + if not content_type: + content_type = mimetypes.guess_type(safe_filename)[0] or "application/octet-stream" + + blob = _get_or_create_blob(session, tenant_id=tenant_id, data=data, filename=safe_filename, content_type=content_type) + asset = FileAsset( + tenant_id=tenant_id, + owner_type=owner_type, + owner_user_id=owner_id if owner_type == "user" else None, + owner_group_id=owner_id if owner_type == "group" else None, + display_path=logical_path, + filename=filename_from_path(logical_path), + description=description, + created_by_user_id=user_id, + metadata_=metadata or {}, + ) + session.add(asset) + session.flush() + version = FileVersion( + tenant_id=tenant_id, + file_asset_id=asset.id, + blob_id=blob.id, + version_number=1, + filename_at_upload=safe_filename, + display_path_at_upload=logical_path, + content_type=content_type, + size_bytes=blob.size_bytes, + checksum_sha256=blob.checksum_sha256, + created_by_user_id=user_id, + ) + session.add(version) + session.flush() + asset.current_version_id = version.id + session.add(asset) + if campaign_id: + share_file(session, tenant_id=tenant_id, asset=asset, target_type="campaign", target_id=campaign_id, permission="read", user_id=user_id) + return UploadedStoredFile(asset=asset, version=version, blob=blob) + + +def get_asset_for_user(session: Session, *, tenant_id: str, user_id: str, asset_id: str, require_write: bool = False, is_admin: bool = False) -> FileAsset: + asset = session.get(FileAsset, asset_id) + if not asset or asset.tenant_id != tenant_id or asset.deleted_at is not None: + raise FileStorageError("File not found") + if is_admin: + return asset + group_ids = user_group_ids(session, tenant_id=tenant_id, user_id=user_id) + owns = (asset.owner_type == "user" and asset.owner_user_id == user_id) or (asset.owner_type == "group" and asset.owner_group_id in group_ids) + if owns: + return asset + permission_values = ["read", "write", "manage"] if not require_write else ["write", "manage"] + share = ( + session.query(FileShare) + .filter( + FileShare.tenant_id == tenant_id, + FileShare.file_asset_id == asset.id, + FileShare.revoked_at.is_(None), + FileShare.permission.in_(permission_values), + or_( + (FileShare.target_type == "user") & (FileShare.target_id == user_id), + (FileShare.target_type == "group") & (FileShare.target_id.in_(group_ids)), + (FileShare.target_type == "tenant") & (FileShare.target_id == tenant_id), + ), + ) + .first() + ) + if not share: + raise FileStorageError("No access to this file") + return asset + + +def list_assets_for_user( + session: Session, + *, + tenant_id: str, + user_id: str, + owner_type: str | None = None, + owner_id: str | None = None, + campaign_id: str | None = None, + path_prefix: str | None = None, + include_deleted: bool = False, + is_admin: bool = False, +) -> list[FileAsset]: + query = session.query(FileAsset).filter(FileAsset.tenant_id == tenant_id) + if not include_deleted: + query = query.filter(FileAsset.deleted_at.is_(None)) + if owner_type: + query = query.filter(FileAsset.owner_type == owner_type) + if owner_type == "user" and owner_id: + query = query.filter(FileAsset.owner_user_id == owner_id) + if owner_type == "group" and owner_id: + query = query.filter(FileAsset.owner_group_id == owner_id) + if campaign_id: + query = query.join(FileShare, FileShare.file_asset_id == FileAsset.id).filter( + FileShare.tenant_id == tenant_id, + FileShare.target_type == "campaign", + FileShare.target_id == campaign_id, + FileShare.revoked_at.is_(None), + ) + elif not is_admin and not owner_type: + group_ids = user_group_ids(session, tenant_id=tenant_id, user_id=user_id) + query = query.outerjoin(FileShare, FileShare.file_asset_id == FileAsset.id).filter( + or_( + (FileAsset.owner_type == "user") & (FileAsset.owner_user_id == user_id), + (FileAsset.owner_type == "group") & (FileAsset.owner_group_id.in_(group_ids)), + (FileShare.revoked_at.is_(None)) & (FileShare.target_type == "user") & (FileShare.target_id == user_id), + (FileShare.revoked_at.is_(None)) & (FileShare.target_type == "group") & (FileShare.target_id.in_(group_ids)), + (FileShare.revoked_at.is_(None)) & (FileShare.target_type == "tenant") & (FileShare.target_id == tenant_id), + ) + ) + if path_prefix: + prefix = normalize_folder(path_prefix) + if prefix: + query = query.filter(FileAsset.display_path.like(f"{prefix}/%")) + return query.order_by(FileAsset.display_path.asc(), FileAsset.updated_at.desc()).all() + + +def current_version_and_blob(session: Session, asset: FileAsset) -> tuple[FileVersion, FileBlob]: + if not asset.current_version_id: + raise FileStorageError("File has no current version") + version = session.get(FileVersion, asset.current_version_id) + if not version: + raise FileStorageError("File version not found") + blob = session.get(FileBlob, version.blob_id) + if not blob: + raise FileStorageError("File blob not found") + return version, blob + + +def read_asset_bytes(session: Session, asset: FileAsset) -> tuple[bytes, FileVersion, FileBlob]: + version, blob = current_version_and_blob(session, asset) + backend = get_storage_backend() + return backend.get_bytes(blob.storage_key), version, blob + + +def share_file( + session: Session, + *, + tenant_id: str, + asset: FileAsset, + target_type: str, + target_id: str, + permission: str, + user_id: str, +) -> FileShare: + target_type = target_type.lower().strip() + permission = permission.lower().strip() + if target_type not in {"user", "group", "campaign", "tenant"}: + raise FileStorageError("Unsupported share target") + if permission not in {"read", "write", "manage"}: + raise FileStorageError("Unsupported file permission") + if target_type == "campaign": + campaign = session.get(Campaign, target_id) + if not campaign or campaign.tenant_id != tenant_id: + raise FileStorageError("Campaign not found") + existing = ( + session.query(FileShare) + .filter( + FileShare.tenant_id == tenant_id, + FileShare.file_asset_id == asset.id, + FileShare.target_type == target_type, + FileShare.target_id == target_id, + FileShare.revoked_at.is_(None), + ) + .one_or_none() + ) + if existing: + existing.permission = permission + session.add(existing) + return existing + share = FileShare( + tenant_id=tenant_id, + file_asset_id=asset.id, + target_type=target_type, + target_id=target_id, + permission=permission, + created_by_user_id=user_id, + ) + session.add(share) + return share + + +def soft_delete_assets(session: Session, assets: Iterable[FileAsset]) -> int: + count = 0 + now = utcnow() + for asset in assets: + if asset.deleted_at is None: + asset.deleted_at = now + session.add(asset) + count += 1 + return count + + +def asset_is_audit_relevant(session: Session, asset: FileAsset) -> bool: + return ( + session.query(CampaignAttachmentUse) + .filter(CampaignAttachmentUse.file_asset_id == asset.id, CampaignAttachmentUse.use_stage == "sent") + .first() + is not None + ) + + +def _normalize_pattern(pattern: str) -> str: + if pattern.strip() in {"", "*"}: + return "*" + return normalize_logical_path(pattern, fallback_filename="*") + + +def _logical_glob_regex(pattern: str) -> re.Pattern[str]: + """Compile Multi Seal Mail logical globs. + + `*` and `?` stay within one folder segment. `**` crosses folder + boundaries, and `**/` also matches the current folder so `**/*.pdf` + returns direct and nested PDF files. + """ + + pattern = _normalize_pattern(pattern) + pieces = ["^"] + index = 0 + while index < len(pattern): + char = pattern[index] + if char == "*": + if index + 1 < len(pattern) and pattern[index + 1] == "*": + index += 2 + if index < len(pattern) and pattern[index] == "/": + pieces.append("(?:.*/)?") + index += 1 + else: + pieces.append(".*") + continue + pieces.append("[^/]*") + elif char == "?": + pieces.append("[^/]") + else: + pieces.append(re.escape(char)) + index += 1 + pieces.append("$") + return re.compile("".join(pieces)) + + +def _relative_display_path(asset: FileAsset, base_path: str | None) -> str: + path = normalize_logical_path(asset.display_path) + base = normalize_folder(base_path) + if not base: + return path + prefix = f"{base}/" + if path.startswith(prefix): + return path[len(prefix) :] + return path + + +def match_assets(assets: Iterable[FileAsset], pattern: str, *, base_path: str | None = None) -> list[FileAsset]: + regex = _logical_glob_regex(pattern) + normalized_pattern = _normalize_pattern(pattern) + has_path_context = base_path is not None or "/" in normalized_pattern or "**" in normalized_pattern + matches: list[FileAsset] = [] + for asset in assets: + candidates = [_relative_display_path(asset, base_path)] if has_path_context else [asset.display_path, asset.filename] + if any(regex.match(candidate) for candidate in candidates): + matches.append(asset) + return matches + + +def resolve_patterns(assets: list[FileAsset], patterns: list[str], *, base_path: str | None = None) -> tuple[list[ResolvedPattern], list[FileAsset]]: + resolved = [ResolvedPattern(pattern=pattern, matches=match_assets(assets, pattern, base_path=base_path)) for pattern in patterns] + matched_ids = {asset.id for item in resolved for asset in item.matches} + unmatched = [asset for asset in assets if asset.id not in matched_ids] + return resolved, unmatched + + +def rename_asset(asset: FileAsset, *, new_path: str) -> None: + normalized = normalize_logical_path(new_path) + asset.display_path = normalized + asset.filename = filename_from_path(normalized) + + +def build_rename_preview(asset: FileAsset, *, mode: str, find: str | None = None, replacement: str = "", prefix: str = "", suffix: str = "") -> str: + path = PurePosixPath(asset.display_path) + folder = "" if str(path.parent) == "." else str(path.parent) + name = path.name + stem = PurePosixPath(name).stem + ext = "".join(PurePosixPath(name).suffixes) + if mode == "prefix": + next_name = prefix + name + elif mode == "suffix": + next_name = f"{stem}{suffix}{ext}" + elif mode == "replace": + if not find: + next_name = name + else: + next_name = name.replace(find, replacement) + else: + raise FileStorageError("Unsupported rename mode") + return f"{folder}/{next_name}" if folder else next_name + + +def create_zip_bytes(session: Session, assets: Iterable[FileAsset]) -> bytes: + buffer = BytesIO() + with zipfile.ZipFile(buffer, mode="w", compression=zipfile.ZIP_DEFLATED) as archive: + for asset in assets: + data, _, _ = read_asset_bytes(session, asset) + archive.writestr(asset.display_path, data) + buffer.seek(0) + return buffer.getvalue() + + +def extract_zip_upload( + session: Session, + *, + tenant_id: str, + owner_type: str, + owner_id: str, + user_id: str, + zip_data: bytes, + folder: str | None, + campaign_id: str | None, + is_admin: bool = False, + max_files: int = 1000, + max_total_bytes: int = 250 * 1024 * 1024, +) -> list[UploadedStoredFile]: + uploaded: list[UploadedStoredFile] = [] + total = 0 + base_folder = normalize_folder(folder) + with zipfile.ZipFile(BytesIO(zip_data)) as archive: + infos = [info for info in archive.infolist() if not info.is_dir()] + if len(infos) > max_files: + raise FileStorageError(f"ZIP contains too many files (limit {max_files})") + for info in infos: + if info.file_size < 0: + raise FileStorageError("Invalid ZIP member") + total += info.file_size + if total > max_total_bytes: + raise FileStorageError("ZIP is too large after extraction") + inner_path = normalize_logical_path(info.filename) + target_path = f"{base_folder}/{inner_path}" if base_folder else inner_path + data = archive.read(info) + uploaded.append( + create_file_asset( + session, + tenant_id=tenant_id, + owner_type=owner_type, + owner_id=owner_id, + user_id=user_id, + filename=filename_from_path(inner_path), + data=data, + display_path=target_path, + content_type=mimetypes.guess_type(inner_path)[0] or "application/octet-stream", + campaign_id=campaign_id, + is_admin=is_admin, + ) + ) + return uploaded + + +def _candidate_match_keys(raw_match: str) -> set[str]: + cleaned = raw_match.replace("\\", "/").strip().strip("/") + result = {cleaned} + if cleaned: + result.add(PurePosixPath(cleaned).name) + return {item for item in result if item} + + +def record_campaign_attachment_uses_for_job(session: Session, job: CampaignJob, *, stage: str = "built") -> None: + """Create best-effort immutable file-use records for matched managed files. + + Existing attachment resolution is still filesystem/path based. This bridge + records uses when a resolved attachment match can be tied to a managed file + by logical path or filename among files shared with the campaign. + """ + + attachments = job.resolved_attachments or [] + if not isinstance(attachments, list): + return + assets = list_assets_for_user( + session, + tenant_id=job.tenant_id, + user_id="", + campaign_id=job.campaign_id, + is_admin=True, + ) + by_key: dict[str, FileAsset] = {} + for asset in assets: + by_key[asset.display_path.strip("/")] = asset + by_key[asset.filename] = asset + for attachment in attachments: + if not isinstance(attachment, dict): + continue + matches = attachment.get("matches") if isinstance(attachment.get("matches"), list) else [] + for raw in matches: + if not isinstance(raw, str): + continue + asset = next((by_key[key] for key in _candidate_match_keys(raw) if key in by_key), None) + if not asset: + continue + version, blob = current_version_and_blob(session, asset) + exists = ( + session.query(CampaignAttachmentUse) + .filter( + CampaignAttachmentUse.campaign_job_id == job.id, + CampaignAttachmentUse.file_version_id == version.id, + CampaignAttachmentUse.filename_used == asset.filename, + CampaignAttachmentUse.use_stage == stage, + ) + .one_or_none() + ) + if exists: + continue + session.add( + CampaignAttachmentUse( + tenant_id=job.tenant_id, + campaign_id=job.campaign_id, + campaign_version_id=job.campaign_version_id, + campaign_job_id=job.id, + entry_index=job.entry_index, + entry_id=job.entry_id, + file_asset_id=asset.id, + file_version_id=version.id, + file_blob_id=blob.id, + filename_used=asset.filename, + checksum_sha256=blob.checksum_sha256, + size_bytes=blob.size_bytes, + content_type=blob.content_type, + use_stage=stage, + ) + ) + + +def mark_job_attachment_uses_sent(session: Session, job: CampaignJob) -> None: + record_campaign_attachment_uses_for_job(session, job, stage="built") + now = utcnow() + uses = ( + session.query(CampaignAttachmentUse) + .filter( + CampaignAttachmentUse.tenant_id == job.tenant_id, + CampaignAttachmentUse.campaign_job_id == job.id, + CampaignAttachmentUse.use_stage == "built", + ) + .all() + ) + for use in uses: + sent = ( + session.query(CampaignAttachmentUse) + .filter( + CampaignAttachmentUse.campaign_job_id == job.id, + CampaignAttachmentUse.file_version_id == use.file_version_id, + CampaignAttachmentUse.use_stage == "sent", + ) + .one_or_none() + ) + if sent: + continue + session.add( + CampaignAttachmentUse( + tenant_id=use.tenant_id, + campaign_id=use.campaign_id, + campaign_version_id=use.campaign_version_id, + campaign_job_id=use.campaign_job_id, + entry_index=use.entry_index, + entry_id=use.entry_id, + file_asset_id=use.file_asset_id, + file_version_id=use.file_version_id, + file_blob_id=use.file_blob_id, + filename_used=use.filename_used, + checksum_sha256=use.checksum_sha256, + size_bytes=use.size_bytes, + content_type=use.content_type, + use_stage="sent", + used_at=now, + ) + ) diff --git a/server/multimailer-dev.db b/server/multimailer-dev.db index 39a31f6f458467a27c42e8d7f2fc24d20949f767..332338a7f9d571facb58719ee9a1d7aa1dafbeee 100644 GIT binary patch literal 1011712 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 delta 5177 zcmb_AYj{paw|n+=X7*)7qS1&5UYd}^n;Qv1f~wL|+O(=@g2bhyL85VKld8JJrHNn> z3E>czPpQL~t)+yJC{dw``h-+lszvdYI@Q*h{U-V9^!a|A^E~H$o_F@z>prvA%v!T^ z>bmEY2j}@UZ5bd4LMi_@{$9=xi3s4>uxSevj#Jqchmye_vis}~yTPupE4#en$1`p8 z7>-YT^}QR+zQIs#C%B%fnQ~N2L?q1XiN@8xM#jcOm&GIXBT^;MDoZy0Fu3#{s?Y9> z^u)x(-p`C~2B&^Sr{$?BX=C6{I(9+zL(~q;;gqQEaCbyhc-e0V^cuLg+$Sd*F&N7{ZgNL12sb2iiQxD?XSOv&@xy*07L$!Ad!P6+`nOArl8FQ@OxA4~SZq1xj+0Mn8%;Yl9ziVdtwj zSjpq8@5AKZ)gZ5DMGiMOy!ubZIj}Rff$=?id4ugj5-NpPEQ%H}Sd^kC5X zbocs?++~4^9EfyViC6Y$qO_S$V1uL%cvba%Jr;5{Ab*G%pm^IW2PhNI`02_lL3=^n zsWw+;nUb-`n53W9yVK2Du^kYo9!9$Y;|nFb*Rx7%(Jp>U9lP1b%j_qZh1M0b!n|Si zU{P#`)y(#)%K}V_fpQ52Pc0} z6+7uib%QK%g`nM5+j2hxOh{j)2ifVrbJ4#Nwm1Y+`(Nt33tFJ&=sapd>;cP!)KtyO zZW5;5L9KZTm|C5{cCl2e%Iat?rF&@(eVQC6uiy)KgjUDJ_G{wI$%PE#W52N7YzN!QC{vhVJ+$uIf$>_kmH&bT z+nDsE%?4@rnl#mv62Y2ce5+Hvsf*{3kgHe*5HeXu-Qa^dRtp(y8B9C+hf^=t<*ST<#9*q|YeTAO| zc8+al@3Y}7l2PkxYmfDTm1=dje9RxsL*^_stDC-_ao3KODcQ$=rM;H-AW~w-na{A4c^X?j_H|zSIU!L7KCwfA!2sjCFnf zpFE3d@a&%B+2Fr;=2(gw>G6!88HjtvANHkQ+_P*d9~NE|&Is(h>@VGw0>%4cmT*Q5 zQO+ydl{v~V#jU9FMUqd(lJ0!r=CEPR%~b26Tqvi@eXakLj!J8!45`0ZEv^>_iZ1jk zszIe_78-(rgg>-d+7K;B{X;#YZdG&CmsQbPU?o{yEX(}P+-DY;i4)8Ov$^q;QE3z# z8O8vkjs9PHjlM~LM^Ds4bctS|JL!D-I_*qN@-5j*eDN)O1Q+3{xIb>KJ!3UD&QyCVeVV8Y21+N!*l3XP-QGLx=KpGwQ5H~w#FX9SU{q=+0LortR9TD%)CbTZN9 zr|;93>f`htx)1$J$|)H{er zbtL)I^I{;~p^9{_8bXJQcWDGAO*v&r{-yO3vwxQ#0)h@G30OLkL5ghEUFfMm3v73ax>|MR3R1e$tTq( z%+9sAz3k}*2KFZ|RoLx;TP`BJGuRL265OGl`oUR;ddNfV(bl1Ehq!sT19V@^hga9) zFv!VmR5i)hVJLxKb2++T9&Qgob2!(7T&|5LY&j3|KIEJ~3T#-2gCS%%<^0#+(rnIu zYZ2$aKDW_u`cn?=OL(-DYjCgS+~=I(>{8Ae+LE*tg(BEgfM>};QX+z)O_~dNLBxCb zem!FCcHUvlaIAkmhi|6V@8kCTWSfiILHS%9T+dUp6T46rq|R$Z>^Q}>bOvKS9tt1- zjJb_Ppe?{#;KBm#TZddc1WNf(FForQ&fE_&7vfIv`9gfR9$T_sb1A+apZ4wL>L_bcVf3T2`i zr+=w>>)V8L`fT!vK2-0dEA%4WMRRE~jX`gtmrzIfFn&V4&CY3QPGL}bIi*2d-&h>9 zm0BN?s{CN3^3$%noNhhsBlX7<@G`Yh^K)v3T&NwAZ<(t|o_N2pdWbHyNKKLb>#K;g z#u#h_>5s%L?S_;g4nzpg$WQQwoO?WsL4~+oeWHZNJF(vjWq`BcOj&LK$iQ!hLSk7Z)u4xSWnlMLADSTYaw0cMD48n=v-#z#iJF~xY%h%%b#_w{qx`W}6iK7&`S zIK3tPgI?wZWgVSON7FvE9aYISa+GW#x#SJ<90?^B{t4IMop?MiydANj-L$4zuV|lZ zJG4A)GOzNHnvZ%8-eU?Yt^i$(!W`@&ukC z1wQekq4z1=(ypk*F-Q)OdWz8L0eMms&Ops6+(Hz-Sn&mZ0%l*t5gvpp9Dj`ISOe2% z4pU3v=o3;Wadh&e^yKkl)57>YFXQ#JH{TMf9+RkSkT~K87w-;T;0UmnUS)8HBY_Z@2i@)b7_|%uU z2Q2*uzTjMo6~VEk3j6(~?g;WOL1tZWv`W3wb`qbeW*^(+=6)VM3 zajuvu_7&TUD!Pg)c{?>1rJ}y5JyKa6D`zDvhb1vT%P?=4)#f&HkvY+Pj&-#{$VAec z_~ASFC|+xo;@Q@1tJ0cf4N0DsGDg_bN=o>1eWs;MX_!dEgHs_XleVw}-l10zgioUZ zqOi&HwEKL6dnk}FowkIU=`;vRr_;|pQ@3|r$4wemEaN5~=@coG7b${ZL?#V@cV^K; zay#jH5ngCd14ZF144%g24`$I2M{_ehJ#*B=@l(ff_fFQ6noX}K1*k&BzXW0b4B8s9 z-=^=D-Nte}nh1)UZ_kl#vOPrWt_5k{yv4lEo6G~e&0NkK%{O>GPf$ZtUsY6Y@>aHj zH?u31*@z*LRex2I}8$ZnI*O9&_{=#$W-kourD@bzli43Y|IQ#E5sX3EkP*0i$H`ma%?cIUl(OttMx<+&fi;aqjiHJ%WGd^Q{+Sp0SX_?6r8&en} z*3wVC{s(V(Y?qj_9DiWK z!N8YmNr=kDhoU^t>gc;Ltc17=yyDN-Uu*WPBl}d(*8_X>Hc~8kK(whIvpWV)l{}Z3 zer_UL^cX%0Hl4rcdIE}iXMg<@3a9)|m{5BY`SH5S2$R%z8?Kg+h3 zSSePV<=xN(rkQ=r7RFuUC~yBV>)StVihfGptY>St^_R7P#yf)gGhINFv?8sa=ce!t z`Gn;0D@zaJgMYvWoz9P&b$+{=$7ibb9bD^0v^~TxrX675LK+4GeVih50vYcgc7rX!(SEFElz7B!^eGUVhShU}iSWe0v050C2VjtGnH;*N^^_h}fKIzDCM s7&zFIx}B?g1AlaEc$Y3^@sxk2l4*@#?Ka05{C*NZ=CZfIRF3v