feat: mail phase 2 - delete, move, drafts

- Add deleted_at field to Mail/MailAccount/MailFolder models
- Add DELETE /{mail_id} endpoint (soft-delete + IMAP EXPUNGE)
- Add POST /{mail_id}/move endpoint (IMAP UID MOVE + folder_id update)
- Add POST /drafts and PUT /drafts/{id} endpoints (save/edit drafts)
- Add imap_delete_mail() and imap_move_mail() service functions
- Add save_draft() and update_draft() service functions
- Frontend: deleteMail, moveMail, saveDraft, updateDraft API functions
- ComposeModal: draft mode with Save Draft button
- MailDetail: delete/move buttons, edit-draft for drafts
- Mail.tsx: bulk delete/move, move dropdown, draft handlers
- i18n: 13 new keys (de/en)
This commit is contained in:
Agent Zero
2026-07-15 19:44:41 +02:00
parent df83cee10c
commit ed1eec87dc
10 changed files with 950 additions and 21 deletions
+100
View File
@@ -39,6 +39,7 @@ from app.plugins.builtins.mail.schemas import (
MailAccountCreate,
MailAccountUpdate,
MailCreateEventRequest,
MailDraftCreate,
MailFlagsUpdate,
MailFolderCreate,
MailFolderUpdate,
@@ -46,6 +47,7 @@ from app.plugins.builtins.mail.schemas import (
MailLabelAssign,
MailLabelCreate,
MailLinkRequest,
MailMoveRequest,
MailReplyRequest,
MailRuleCreate,
MailSendRequest,
@@ -71,6 +73,8 @@ from app.plugins.builtins.mail.services import (
folder_to_response,
forward_mail,
get_account_password,
imap_delete_mail,
imap_move_mail,
imap_sync_account,
import_pgp_private_key,
import_pgp_public_key,
@@ -80,11 +84,13 @@ from app.plugins.builtins.mail.services import (
pgp_encrypt_message,
reply_to_mail,
rule_to_response,
save_draft,
send_mail_via_smtp,
should_send_vacation_reply,
signature_to_response,
substitute_template_vars,
template_to_response,
update_draft,
update_mail_account,
)
@@ -1377,6 +1383,100 @@ async def assign_label(
return {"assigned": True, "label_id": str(l_id)}
# ─── Drafts (F-MAIL-15) ───
@router.post("/drafts", status_code=201)
async def create_draft(
data: MailDraftCreate,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_id"])
acc_id = _parse_uuid(data.account_id, "account_id")
await _get_account(db, acc_id, tenant_id, user_id)
mail = await save_draft(db, acc_id, tenant_id, user_id, data.model_dump())
return mail_to_response(mail)
@router.put("/drafts/{mail_id}")
async def update_draft_route(
mail_id: str,
data: MailDraftCreate,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
tenant_id = uuid.UUID(current_user["tenant_id"])
m_id = _parse_uuid(mail_id, "mail_id")
mail = await update_draft(db, m_id, tenant_id, data.model_dump())
return mail_to_response(mail)
# ─── Delete / Move (dynamic /{mail_id} routes) ───
@router.delete("/{mail_id}", status_code=204)
async def delete_mail(
mail_id: str,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
tenant_id = uuid.UUID(current_user["tenant_id"])
m_id = _parse_uuid(mail_id, "mail_id")
mail = (
await db.execute(select(Mail).where(and_(Mail.id == m_id, Mail.tenant_id == tenant_id)))
).scalar_one_or_none()
if not mail:
raise HTTPException(404, detail={"detail": "Mail not found", "code": "not_found"})
# Soft-delete: set deleted_at = now()
mail.deleted_at = datetime.now(UTC)
await db.flush()
# IMAP delete: non-critical, errors logged but DB soft-delete still succeeds
try:
await mail_services.imap_delete_mail(db, m_id, tenant_id)
except Exception as exc:
import logging
logging.getLogger(__name__).warning("IMAP delete failed for mail %s: %s", m_id, exc)
@router.post("/{mail_id}/move")
async def move_mail(
mail_id: str,
data: MailMoveRequest,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
tenant_id = uuid.UUID(current_user["tenant_id"])
m_id = _parse_uuid(mail_id, "mail_id")
target_f_id = _parse_uuid(data.target_folder_id, "target_folder_id")
mail = (
await db.execute(select(Mail).where(and_(Mail.id == m_id, Mail.tenant_id == tenant_id)))
).scalar_one_or_none()
if not mail:
raise HTTPException(404, detail={"detail": "Mail not found", "code": "not_found"})
# Verify target folder exists and belongs to same tenant
target_folder = (
await db.execute(
select(MailFolder).where(
and_(MailFolder.id == target_f_id, MailFolder.tenant_id == tenant_id)
)
)
).scalar_one_or_none()
if not target_folder:
raise HTTPException(404, detail={"detail": "Target folder not found", "code": "not_found"})
# Update mail.folder_id
mail.folder_id = target_f_id
await db.flush()
# IMAP move: non-critical
try:
await mail_services.imap_move_mail(db, m_id, target_f_id, tenant_id)
except Exception as exc:
import logging
logging.getLogger(__name__).warning("IMAP move failed for mail %s: %s", m_id, exc)
return mail_to_response(mail)
# ═══════════════════════════════════════════════════════════════
# DYNAMIC ROUTES — registered LAST so static routes are not shadowed
# ═══════════════════════════════════════════════════════════════