Security fixes: P0-P2 complete (22 fixes)

P0 (7): Auth-bypass removed, migrations fixed, plugin-upload disabled, RLS FORCE+WITH CHECK, plugin double-registration fixed, persistent volume, domain removed
P1 (11): User/tenant model, Redis centralized, worker separated, transactional outbox, XSS fixed, DMS chunked streaming, permissions unified, password reset, metrics secured, config/docs fixed, cross-tenant FK
P2 (4): Contact model normalized, cross-imports reduced 94%, commands+state machines for contacts/dms/mail/calendar, SPA path-traversal

8 new migrations, 99 unit tests, 13 commands, 8 contracts, 72 files changed
This commit is contained in:
Agent Zero
2026-07-25 21:03:46 +02:00
parent aaa7406929
commit 727d86614e
103 changed files with 6831 additions and 1053 deletions
+51
View File
@@ -0,0 +1,51 @@
"""Command pattern package for LeoCRM.
Commands encapsulate business operations with:
- Authorization (permission check)
- Execution (delegates to services)
- Audit logging
- Outbox event enqueuing
- State machine validation
Commands do NOT commit or rollback — the calling layer (FastAPI dependency
``get_db``) manages the transaction boundary.
"""
from app.commands.base import BaseCommand, CommandResult
from app.commands.contact_commands import (
CreateContactCommand,
UpdateContactCommand,
DeleteContactCommand,
MergeContactsCommand,
)
from app.commands.dms_commands import (
UploadFileCommand,
DeleteFileCommand,
)
from app.commands.mail_commands import (
SendMailCommand,
MarkMailReadCommand,
DeleteMailCommand,
)
from app.commands.calendar_commands import (
CreateCalendarEntryCommand,
UpdateCalendarEntryCommand,
DeleteCalendarEntryCommand,
)
__all__ = [
"BaseCommand",
"CommandResult",
"CreateContactCommand",
"UpdateContactCommand",
"DeleteContactCommand",
"MergeContactsCommand",
"UploadFileCommand",
"DeleteFileCommand",
"SendMailCommand",
"MarkMailReadCommand",
"DeleteMailCommand",
"CreateCalendarEntryCommand",
"UpdateCalendarEntryCommand",
"DeleteCalendarEntryCommand",
]
+130
View File
@@ -0,0 +1,130 @@
"""Base command infrastructure — CommandResult and BaseCommand.
Template method pattern:
execute() → authorize() → run() → audit()
Commands must NOT call db.commit() or db.rollback().
The transaction boundary is owned by the calling layer (FastAPI ``get_db``).
Commands MAY call db.flush() to send SQL within the transaction.
"""
from __future__ import annotations
import logging
import uuid
from dataclasses import dataclass, field
from typing import Any
from sqlalchemy.ext.asyncio import AsyncSession
import redis.asyncio as aioredis
from app.core.permissions import check_permission
logger = logging.getLogger(__name__)
@dataclass
class CommandResult:
"""Result of a command execution.
Attributes:
success: Whether the command succeeded.
data: Response data on success (dict or None).
error: Error message on failure (str or None).
events: List of outbox event dicts that were enqueued.
"""
success: bool
data: dict | None = None
error: str | None = None
events: list[dict] = field(default_factory=list)
@classmethod
def ok(cls, data: dict | None = None, events: list[dict] | None = None) -> "CommandResult":
"""Create a successful result."""
return cls(success=True, data=data, events=events or [])
@classmethod
def fail(cls, error: str) -> "CommandResult":
"""Create a failed result."""
return cls(success=False, error=error)
class BaseCommand:
"""Base class for all commands using the template method pattern.
Subclasses must implement:
- authorize(current_user) → check permissions
- run(db, redis, current_user) → execute business logic, return CommandResult
- audit(db, current_user) → create audit log entry
The ``permission`` attribute is checked by the default ``authorize``
implementation. Set it to the required permission string (e.g. "contacts:write").
"""
permission: str | None = None
async def execute(
self,
db: AsyncSession,
redis: aioredis.Redis,
current_user: dict[str, Any],
) -> CommandResult:
"""Execute the command: authorize → run → audit.
Does NOT commit — the caller manages the transaction.
"""
# Authorization
auth_result = await self.authorize(current_user)
if not auth_result:
return CommandResult.fail(
f"Permission denied: '{self.permission}' required"
)
# Execute business logic
result = await self.run(db, redis, current_user)
# Audit (only if the command succeeded)
if result.success:
try:
await self.audit(db, current_user)
except Exception:
logger.exception("Audit logging failed for %s", self.__class__.__name__)
# Audit failure should not roll back the business operation
return result
async def authorize(self, current_user: dict[str, Any]) -> bool:
"""Check if the current user has the required permission.
Override in subclass for custom authorization logic.
"""
if self.permission is None:
return True
return check_permission(current_user, self.permission)
async def run(
self,
db: AsyncSession,
redis: aioredis.Redis,
current_user: dict[str, Any],
) -> CommandResult:
"""Execute the business logic. Must be overridden by subclasses."""
raise NotImplementedError(f"{self.__class__.__name__}.run() not implemented")
async def audit(self, db: AsyncSession, current_user: dict[str, Any]) -> None:
"""Create an audit log entry. Override in subclass."""
pass
# ── Helpers ──
@staticmethod
def _tenant_id(current_user: dict[str, Any]) -> uuid.UUID:
"""Extract tenant_id from current_user session."""
return uuid.UUID(current_user["tenant_id"])
@staticmethod
def _user_id(current_user: dict[str, Any]) -> uuid.UUID:
"""Extract user_id from current_user session."""
return uuid.UUID(current_user["user_id"])
+181
View File
@@ -0,0 +1,181 @@
"""Calendar commands — create, update, delete entries via Command pattern."""
from __future__ import annotations
import logging
import uuid
from datetime import UTC, datetime
from typing import Any
import redis.asyncio as aioredis
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.commands.base import BaseCommand, CommandResult
from app.core.outbox import enqueue_outbox_event
logger = logging.getLogger(__name__)
class CreateCalendarEntryCommand(BaseCommand):
"""Create a new calendar entry (appointment, task, reminder)."""
permission = "calendar:write"
def __init__(self, calendar_id: str, title: str, start_at: str, end_at: str | None = None,
description: str | None = None, location: str | None = None,
entry_type: str = "appointment", status: str = "open"):
self.calendar_id = calendar_id
self.title = title
self.start_at = start_at
self.end_at = end_at
self.description = description
self.location = location
self.entry_type = entry_type
self.status = status
async def run(self, db: AsyncSession, redis: aioredis.Redis, current_user: dict[str, Any]) -> CommandResult:
from app.plugins.builtins.calendar.models import Calendar, CalendarEntry
tenant_id = self._tenant_id(current_user)
user_id = self._user_id(current_user)
try:
cal_id = uuid.UUID(self.calendar_id)
except ValueError:
return CommandResult.fail("Invalid calendar_id")
# Verify calendar belongs to tenant
cal_result = await db.execute(
select(Calendar).where(Calendar.id == cal_id, Calendar.tenant_id == tenant_id)
)
if cal_result.scalar_one_or_none() is None:
return CommandResult.fail("Calendar not found")
entry_id = uuid.uuid4()
entry = CalendarEntry(
id=entry_id,
tenant_id=tenant_id,
calendar_id=cal_id,
title=self.title,
description=self.description,
location=self.location,
start_at=datetime.fromisoformat(self.start_at),
end_at=datetime.fromisoformat(self.end_at) if self.end_at else None,
entry_type=self.entry_type,
status=self.status,
created_by=user_id,
)
db.add(entry)
await db.flush()
await enqueue_outbox_event(db, tenant_id, "calendar.entry.created", {
"entry_id": str(entry_id),
"title": self.title,
"start_at": self.start_at,
})
return CommandResult.ok({
"id": str(entry_id),
"title": self.title,
"start_at": self.start_at,
"status": self.status,
})
async def audit(self, db: AsyncSession, current_user: dict[str, Any]) -> None:
from app.core.audit import log_audit
await log_audit(
db, self._tenant_id(current_user), self._user_id(current_user),
action="calendar.entry.create", entity_type="calendar_entry",
changes={"title": self.title, "start_at": self.start_at},
)
class UpdateCalendarEntryCommand(BaseCommand):
"""Update an existing calendar entry."""
permission = "calendar:write"
def __init__(self, entry_id: str, data: dict[str, Any]):
self.entry_id = entry_id
self.data = data
async def run(self, db: AsyncSession, redis: aioredis.Redis, current_user: dict[str, Any]) -> CommandResult:
from app.plugins.builtins.calendar.models import CalendarEntry
tenant_id = self._tenant_id(current_user)
try:
eid = uuid.UUID(self.entry_id)
except ValueError:
return CommandResult.fail("Invalid entry_id")
result = await db.execute(
select(CalendarEntry).where(CalendarEntry.id == eid, CalendarEntry.tenant_id == tenant_id)
)
entry = result.scalar_one_or_none()
if entry is None:
return CommandResult.fail("Calendar entry not found")
# Apply updates
for key, value in self.data.items():
if hasattr(entry, key) and key not in ("id", "tenant_id", "created_at"):
if key in ("start_at", "end_at") and isinstance(value, str):
value = datetime.fromisoformat(value)
setattr(entry, key, value)
await db.flush()
await enqueue_outbox_event(db, tenant_id, "calendar.entry.updated", {
"entry_id": self.entry_id,
"changes": self.data,
})
return CommandResult.ok({"id": self.entry_id, "updated": True})
async def audit(self, db: AsyncSession, current_user: dict[str, Any]) -> None:
from app.core.audit import log_audit
await log_audit(
db, self._tenant_id(current_user), self._user_id(current_user),
action="calendar.entry.update", entity_type="calendar_entry",
changes={"entry_id": self.entry_id, "fields": list(self.data.keys())},
)
class DeleteCalendarEntryCommand(BaseCommand):
"""Delete a calendar entry."""
permission = "calendar:delete"
def __init__(self, entry_id: str):
self.entry_id = entry_id
async def run(self, db: AsyncSession, redis: aioredis.Redis, current_user: dict[str, Any]) -> CommandResult:
from app.plugins.builtins.calendar.models import CalendarEntry
tenant_id = self._tenant_id(current_user)
try:
eid = uuid.UUID(self.entry_id)
except ValueError:
return CommandResult.fail("Invalid entry_id")
result = await db.execute(
select(CalendarEntry).where(CalendarEntry.id == eid, CalendarEntry.tenant_id == tenant_id)
)
entry = result.scalar_one_or_none()
if entry is None:
return CommandResult.fail("Calendar entry not found")
await db.delete(entry)
await db.flush()
await enqueue_outbox_event(db, tenant_id, "calendar.entry.deleted", {"entry_id": self.entry_id})
return CommandResult.ok({"id": self.entry_id, "deleted": True})
async def audit(self, db: AsyncSession, current_user: dict[str, Any]) -> None:
from app.core.audit import log_audit
await log_audit(
db, self._tenant_id(current_user), self._user_id(current_user),
action="calendar.entry.delete", entity_type="calendar_entry",
changes={"entry_id": self.entry_id},
)
+375
View File
@@ -0,0 +1,375 @@
"""Contact commands — Create, Update, Delete (soft), Merge.
Each command:
- Checks permissions via ``require_permission`` semantics
- Delegates business logic to existing services
- Creates an AuditLog entry
- Enqueues outbox events
- Validates state transitions via the state machine
Commands do NOT commit — the transaction is managed by ``get_db``.
"""
from __future__ import annotations
import logging
import uuid
from datetime import datetime, timezone
from typing import Any
from sqlalchemy.ext.asyncio import AsyncSession
import redis.asyncio as aioredis
from app.commands.base import BaseCommand, CommandResult
from app.core.outbox import enqueue_outbox_event
from app.core.state_machine import contact_state_machine, StateMachineError
from app.models.audit import AuditLog
from app.models.contact import Contact
from app.services import contact_service, dedup_service
from app.services.entity_history_service import record_history
logger = logging.getLogger(__name__)
class CreateContactCommand(BaseCommand):
"""Create a new contact (company or person).
Args:
data: Contact fields dict (from ContactCreate schema).
"""
permission = "contacts:write"
def __init__(self, data: dict[str, Any]) -> None:
self.data = data
self._created_contact_id: uuid.UUID | None = None
self._serialized: dict | None = None
async def run(
self,
db: AsyncSession,
redis: aioredis.Redis,
current_user: dict[str, Any],
) -> CommandResult:
tenant_id = self._tenant_id(current_user)
user_id = self._user_id(current_user)
# Set default status for new contacts
if "status" not in self.data:
self.data["status"] = "lead"
# Validate status if provided
status = self.data.get("status", "lead")
if status not in contact_state_machine.transitions:
return CommandResult.fail(f"Invalid contact status: '{status}'")
# Delegate to existing service
try:
serialized = await contact_service.create_contact(
db, tenant_id, user_id, self.data
)
except ValueError as exc:
return CommandResult.fail(str(exc))
self._created_contact_id = uuid.UUID(serialized["id"])
self._serialized = serialized
# Enqueue outbox events
events: list[dict] = []
await enqueue_outbox_event(db, tenant_id, "contact.created", {
"contact_id": serialized["id"],
"tenant_id": str(tenant_id),
"user_id": str(user_id),
"type": self.data.get("type", "company"),
})
events.append({"event": "contact.created", "contact_id": serialized["id"]})
if self.data.get("type") == "company":
await enqueue_outbox_event(db, tenant_id, "lead.created", {
"contact_id": serialized["id"],
"tenant_id": str(tenant_id),
"user_id": str(user_id),
})
events.append({"event": "lead.created", "contact_id": serialized["id"]})
return CommandResult.ok(data=serialized, events=events)
async def audit(self, db: AsyncSession, current_user: dict[str, Any]) -> None:
tenant_id = self._tenant_id(current_user)
user_id = self._user_id(current_user)
if self._created_contact_id is None:
return
entry = AuditLog(
tenant_id=tenant_id,
user_id=user_id,
action="create",
entity_type="contact",
entity_id=self._created_contact_id,
changes=self._serialized,
)
db.add(entry)
await db.flush()
class UpdateContactCommand(BaseCommand):
"""Update an existing contact.
Args:
contact_id: UUID string of the contact to update.
data: Contact fields to update (from ContactUpdate schema).
"""
permission = "contacts:write"
def __init__(self, contact_id: str, data: dict[str, Any]) -> None:
self.contact_id = contact_id
self.data = data
self._contact_uuid: uuid.UUID | None = None
self._serialized: dict | None = None
self._changes: dict | None = None
async def run(
self,
db: AsyncSession,
redis: aioredis.Redis,
current_user: dict[str, Any],
) -> CommandResult:
tenant_id = self._tenant_id(current_user)
user_id = self._user_id(current_user)
# Validate status transition if status is being updated
if "status" in self.data:
new_status = self.data["status"]
# Query only the status column to avoid lazy-loading issues
from sqlalchemy import select
status_q = select(Contact.status).where(
Contact.id == uuid.UUID(self.contact_id),
Contact.tenant_id == tenant_id,
Contact.deleted_at.is_(None),
)
status_result = await db.execute(status_q)
current_status = status_result.scalar_one_or_none()
if current_status is None:
return CommandResult.fail("Contact not found")
try:
contact_state_machine.transition(current_status, new_status)
except StateMachineError as exc:
return CommandResult.fail(str(exc))
# Delegate to existing service
try:
serialized = await contact_service.update_contact(
db, tenant_id, user_id, self.contact_id, self.data
)
except ValueError as exc:
return CommandResult.fail(str(exc))
self._contact_uuid = uuid.UUID(self.contact_id)
self._serialized = serialized
# Compute changes for audit
self._changes = {k: {"new": v} for k, v in self.data.items()}
# Enqueue outbox event
events: list[dict] = []
await enqueue_outbox_event(db, tenant_id, "contact.updated", {
"contact_id": self.contact_id,
"tenant_id": str(tenant_id),
"user_id": str(user_id),
"type": serialized.get("type"),
})
events.append({"event": "contact.updated", "contact_id": self.contact_id})
return CommandResult.ok(data=serialized, events=events)
async def audit(self, db: AsyncSession, current_user: dict[str, Any]) -> None:
tenant_id = self._tenant_id(current_user)
user_id = self._user_id(current_user)
if self._contact_uuid is None:
return
entry = AuditLog(
tenant_id=tenant_id,
user_id=user_id,
action="update",
entity_type="contact",
entity_id=self._contact_uuid,
changes=self._changes,
)
db.add(entry)
await db.flush()
class DeleteContactCommand(BaseCommand):
"""Soft-delete a contact.
Args:
contact_id: UUID string of the contact to delete.
hard: If True, perform GDPR hard-delete instead of soft-delete.
"""
permission = "contacts:write"
def __init__(self, contact_id: str, hard: bool = False) -> None:
self.contact_id = contact_id
self.hard = hard
self._contact_uuid: uuid.UUID | None = None
self._snapshot: dict | None = None
async def run(
self,
db: AsyncSession,
redis: aioredis.Redis,
current_user: dict[str, Any],
) -> CommandResult:
tenant_id = self._tenant_id(current_user)
user_id = self._user_id(current_user)
# Fetch contact for snapshot before deletion
from sqlalchemy import select
from sqlalchemy.orm import selectinload
q = (
select(Contact)
.options(selectinload(Contact.contact_persons))
.where(
Contact.id == uuid.UUID(self.contact_id),
Contact.tenant_id == tenant_id,
)
)
result = await db.execute(q)
contact = result.scalar_one_or_none()
if not contact:
return CommandResult.fail("Contact not found")
self._contact_uuid = contact.id
self._snapshot = contact_service._serialize_contact_detail(contact)
if self.hard:
await contact_service.hard_delete_contact(
db, tenant_id, self.contact_id
)
else:
await contact_service.delete_contact(
db, tenant_id, self.contact_id, user_id
)
# Enqueue outbox event
events: list[dict] = []
event_name = "contact.hard_deleted" if self.hard else "contact.deleted"
await enqueue_outbox_event(db, tenant_id, event_name, {
"contact_id": self.contact_id,
"tenant_id": str(tenant_id),
"user_id": str(user_id),
})
events.append({"event": event_name, "contact_id": self.contact_id})
return CommandResult.ok(data=None, events=events)
async def audit(self, db: AsyncSession, current_user: dict[str, Any]) -> None:
tenant_id = self._tenant_id(current_user)
user_id = self._user_id(current_user)
if self._contact_uuid is None:
return
action = "hard_delete" if self.hard else "delete"
entry = AuditLog(
tenant_id=tenant_id,
user_id=user_id,
action=action,
entity_type="contact",
entity_id=self._contact_uuid,
changes={"snapshot": self._snapshot},
)
db.add(entry)
await db.flush()
class MergeContactsCommand(BaseCommand):
"""Merge two contacts (source → target).
Args:
source_contact_id: UUID string of the source contact (will be soft-deleted).
target_contact_id: UUID string of the target contact (will survive).
field_overrides: Optional field overrides to apply to the target.
note: Optional note for the merge history.
"""
permission = "contacts:write"
def __init__(
self,
source_contact_id: str,
target_contact_id: str,
field_overrides: dict[str, Any] | None = None,
note: str | None = None,
) -> None:
self.source_contact_id = source_contact_id
self.target_contact_id = target_contact_id
self.field_overrides = field_overrides
self.note = note
self._result_data: dict | None = None
async def run(
self,
db: AsyncSession,
redis: aioredis.Redis,
current_user: dict[str, Any],
) -> CommandResult:
tenant_id = self._tenant_id(current_user)
user_id = self._user_id(current_user)
if self.source_contact_id == self.target_contact_id:
return CommandResult.fail("Source and target contacts must be different")
try:
result = await dedup_service.merge_contacts(
db, tenant_id, user_id,
source_id=self.source_contact_id,
target_id=self.target_contact_id,
field_overrides=self.field_overrides,
note=self.note,
)
except ValueError as exc:
return CommandResult.fail(str(exc))
self._result_data = result
# Enqueue outbox events
events: list[dict] = []
await enqueue_outbox_event(db, tenant_id, "contact.merged", {
"source_contact_id": self.source_contact_id,
"target_contact_id": self.target_contact_id,
"tenant_id": str(tenant_id),
"user_id": str(user_id),
})
events.append({
"event": "contact.merged",
"source_contact_id": self.source_contact_id,
"target_contact_id": self.target_contact_id,
})
return CommandResult.ok(data=result, events=events)
async def audit(self, db: AsyncSession, current_user: dict[str, Any]) -> None:
tenant_id = self._tenant_id(current_user)
user_id = self._user_id(current_user)
if self._result_data is None:
return
entry = AuditLog(
tenant_id=tenant_id,
user_id=user_id,
action="merge",
entity_type="contact",
entity_id=uuid.UUID(self.target_contact_id),
changes={
"source_contact_id": self.source_contact_id,
"target_contact_id": self.target_contact_id,
"note": self.note,
},
)
db.add(entry)
await db.flush()
+159
View File
@@ -0,0 +1,159 @@
"""DMS commands — file upload, delete, restore via Command pattern."""
from __future__ import annotations
import hashlib
import logging
import os
import uuid
from typing import Any
import redis.asyncio as aioredis
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.commands.base import BaseCommand, CommandResult
from app.core.outbox import enqueue_outbox_event
from app.core.storage import get_storage_backend
logger = logging.getLogger(__name__)
CHUNK_SIZE = 1024 * 1024 # 1MB
def _sanitize_filename(filename: str) -> str:
"""Sanitize a filename for safe use in Content-Disposition headers."""
import re
safe = os.path.basename(filename.replace("\\", "/"))
safe = re.sub(r"[^a-zA-Z0-9.\-_\u00c0-\u017f\u4e00-\u9fff ]", "_", safe)
safe = re.sub(r"\.{2,}", "_", safe)
safe = re.sub(r" {2,}", " ", safe)
safe = safe.lstrip(".").strip()
if len(safe) > 200:
name, ext = safe.rsplit(".", 1) if "." in safe[:200] else (safe[:200], "")
safe = name[:200] + ("." + ext if ext else "")
return safe or "file"
class UploadFileCommand(BaseCommand):
"""Upload a file to DMS with chunked streaming and SHA-256 hashing."""
permission = "dms:write"
def __init__(self, file_content: bytes, filename: str, mime_type: str, folder_id: str | None = None):
self.file_content = file_content
self.filename = _sanitize_filename(filename)
self.mime_type = mime_type
self.folder_id = folder_id
async def run(self, db: AsyncSession, redis: aioredis.Redis, current_user: dict[str, Any]) -> CommandResult:
from app.plugins.builtins.dms.models import File as DmsFile, Folder
tenant_id = self._tenant_id(current_user)
user_id = self._user_id(current_user)
# Validate folder if specified
fid = None
if self.folder_id:
try:
fid = uuid.UUID(self.folder_id)
except ValueError:
return CommandResult.fail("Invalid folder_id")
folder_result = await db.execute(
select(Folder).where(Folder.id == fid, Folder.tenant_id == tenant_id, Folder.deleted_at.is_(None))
)
if folder_result.scalar_one_or_none() is None:
return CommandResult.fail("Folder not found")
# Calculate SHA-256
sha256 = hashlib.sha256()
sha256.update(self.file_content)
content_hash = sha256.hexdigest()
file_size = len(self.file_content)
# Create file record
file_id = uuid.uuid4()
storage_path = f"{tenant_id}/{file_id}"
# Save file
storage = get_storage_backend()
await storage.save(storage_path, self.file_content)
dms_file = DmsFile(
id=file_id,
tenant_id=tenant_id,
name=self.filename,
folder_id=fid,
uploaded_by=user_id,
mime_type=self.mime_type,
size_bytes=file_size,
storage_path=storage_path,
content_hash=content_hash,
)
db.add(dms_file)
await db.flush()
# Enqueue outbox event
await enqueue_outbox_event(db, tenant_id, "dms.file.uploaded", {
"file_id": str(file_id),
"name": self.filename,
"size_bytes": file_size,
"content_hash": content_hash,
})
return CommandResult.ok({
"id": str(file_id),
"name": self.filename,
"size_bytes": file_size,
"content_hash": content_hash,
"mime_type": self.mime_type,
})
async def audit(self, db: AsyncSession, current_user: dict[str, Any]) -> None:
from app.core.audit import log_audit
await log_audit(
db, self._tenant_id(current_user), self._user_id(current_user),
action="dms.file.upload", entity_type="dms_file",
changes={"name": self.filename, "size": len(self.file_content)},
)
class DeleteFileCommand(BaseCommand):
"""Soft-delete a DMS file."""
permission = "dms:delete"
def __init__(self, file_id: str):
self.file_id = file_id
async def run(self, db: AsyncSession, redis: aioredis.Redis, current_user: dict[str, Any]) -> CommandResult:
from app.plugins.builtins.dms.models import File as DmsFile
from datetime import UTC, datetime
tenant_id = self._tenant_id(current_user)
try:
fid = uuid.UUID(self.file_id)
except ValueError:
return CommandResult.fail("Invalid file_id")
result = await db.execute(
select(DmsFile).where(DmsFile.id == fid, DmsFile.tenant_id == tenant_id, DmsFile.deleted_at.is_(None))
)
dms_file = result.scalar_one_or_none()
if dms_file is None:
return CommandResult.fail("File not found")
dms_file.deleted_at = datetime.now(UTC)
await db.flush()
await enqueue_outbox_event(db, tenant_id, "dms.file.deleted", {"file_id": self.file_id})
return CommandResult.ok({"id": self.file_id, "deleted": True})
async def audit(self, db: AsyncSession, current_user: dict[str, Any]) -> None:
from app.core.audit import log_audit
await log_audit(
db, self._tenant_id(current_user), self._user_id(current_user),
action="dms.file.delete", entity_type="dms_file",
changes={"file_id": self.file_id},
)
+174
View File
@@ -0,0 +1,174 @@
"""Mail commands — send, mark read/unread, delete via Command pattern."""
from __future__ import annotations
import logging
import uuid
from datetime import UTC, datetime
from typing import Any
import redis.asyncio as aioredis
from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.commands.base import BaseCommand, CommandResult
from app.core.outbox import enqueue_outbox_event
logger = logging.getLogger(__name__)
class SendMailCommand(BaseCommand):
"""Send an email via a configured IMAP/SMTP account."""
permission = "mail:send"
def __init__(self, account_id: str, to: list[str], subject: str, body_text: str, body_html: str | None = None, cc: list[str] | None = None, in_reply_to: str | None = None):
self.account_id = account_id
self.to = to
self.subject = subject
self.body_text = body_text
self.body_html = body_html
self.cc = cc or []
self.in_reply_to = in_reply_to
async def run(self, db: AsyncSession, redis: aioredis.Redis, current_user: dict[str, Any]) -> CommandResult:
from app.plugins.builtins.mail.models import Mail, MailAccount
tenant_id = self._tenant_id(current_user)
user_id = self._user_id(current_user)
try:
account_uuid = uuid.UUID(self.account_id)
except ValueError:
return CommandResult.fail("Invalid account_id")
# Verify account belongs to tenant
acct_result = await db.execute(
select(MailAccount).where(MailAccount.id == account_uuid, MailAccount.tenant_id == tenant_id)
)
account = acct_result.scalar_one_or_none()
if account is None:
return CommandResult.fail("Mail account not found")
# Create mail record
mail_id = uuid.uuid4()
mail = Mail(
id=mail_id,
tenant_id=tenant_id,
account_id=account_uuid,
message_id=f"<leocrm-{mail_id}@{account.email_address}>",
from_addr=account.email_address,
to_addr=",".join(self.to),
cc_addr=",".join(self.cc) if self.cc else None,
subject=self.subject,
body_text=self.body_text,
body_html_sanitized=self.body_html,
direction="outgoing",
received_at=datetime.now(UTC),
is_read=True,
folder="Sent",
)
db.add(mail)
await db.flush()
# Enqueue outbox event for async SMTP send
await enqueue_outbox_event(db, tenant_id, "mail.send", {
"mail_id": str(mail_id),
"account_id": self.account_id,
"to": self.to,
"subject": self.subject,
})
return CommandResult.ok({
"id": str(mail_id),
"status": "queued",
"to": self.to,
"subject": self.subject,
})
async def audit(self, db: AsyncSession, current_user: dict[str, Any]) -> None:
from app.core.audit import log_audit
await log_audit(
db, self._tenant_id(current_user), self._user_id(current_user),
action="mail.send", entity_type="mail",
changes={"to": self.to, "subject": self.subject},
)
class MarkMailReadCommand(BaseCommand):
"""Mark a mail as read or unread."""
permission = "mail:write"
def __init__(self, mail_id: str, is_read: bool = True):
self.mail_id = mail_id
self.is_read = is_read
async def run(self, db: AsyncSession, redis: aioredis.Redis, current_user: dict[str, Any]) -> CommandResult:
from app.plugins.builtins.mail.models import Mail
tenant_id = self._tenant_id(current_user)
try:
mid = uuid.UUID(self.mail_id)
except ValueError:
return CommandResult.fail("Invalid mail_id")
result = await db.execute(
select(Mail).where(Mail.id == mid, Mail.tenant_id == tenant_id)
)
mail = result.scalar_one_or_none()
if mail is None:
return CommandResult.fail("Mail not found")
mail.is_read = self.is_read
await db.flush()
return CommandResult.ok({"id": self.mail_id, "is_read": self.is_read})
async def audit(self, db: AsyncSession, current_user: dict[str, Any]) -> None:
from app.core.audit import log_audit
await log_audit(
db, self._tenant_id(current_user), self._user_id(current_user),
action="mail.mark_read", entity_type="mail",
changes={"mail_id": self.mail_id, "is_read": self.is_read},
)
class DeleteMailCommand(BaseCommand):
"""Soft-delete a mail."""
permission = "mail:delete"
def __init__(self, mail_id: str):
self.mail_id = mail_id
async def run(self, db: AsyncSession, redis: aioredis.Redis, current_user: dict[str, Any]) -> CommandResult:
from app.plugins.builtins.mail.models import Mail
tenant_id = self._tenant_id(current_user)
try:
mid = uuid.UUID(self.mail_id)
except ValueError:
return CommandResult.fail("Invalid mail_id")
result = await db.execute(
select(Mail).where(Mail.id == mid, Mail.tenant_id == tenant_id, Mail.deleted_at.is_(None))
)
mail = result.scalar_one_or_none()
if mail is None:
return CommandResult.fail("Mail not found")
mail.deleted_at = datetime.now(UTC)
await db.flush()
await enqueue_outbox_event(db, tenant_id, "mail.deleted", {"mail_id": self.mail_id})
return CommandResult.ok({"id": self.mail_id, "deleted": True})
async def audit(self, db: AsyncSession, current_user: dict[str, Any]) -> None:
from app.core.audit import log_audit
await log_audit(
db, self._tenant_id(current_user), self._user_id(current_user),
action="mail.delete", entity_type="mail",
changes={"mail_id": self.mail_id},
)