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:
@@ -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
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
|
||||
Reference in New Issue
Block a user