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
+3
View File
@@ -48,6 +48,7 @@ class MailAccount(Base, TenantMixin):
encrypted_password: Mapped[str] = mapped_column(Text, nullable=False)
is_shared: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
# --- Mail Folders (F-MAIL-01, F-MAIL-19) ---
@@ -80,6 +81,7 @@ class MailFolder(Base, TenantMixin):
is_standard: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
unread_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
total_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
# --- Mails (F-MAIL-01, F-MAIL-03, F-MAIL-05) ---
@@ -132,6 +134,7 @@ class Mail(Base, TenantMixin):
sent_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
contact_id: Mapped[uuid.UUID | None] = mapped_column(PGUUID(as_uuid=True), nullable=True)
company_id: Mapped[uuid.UUID | None] = mapped_column(PGUUID(as_uuid=True), nullable=True)
deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
# --- Mail Attachments (F-MAIL-04) ---
+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
# ═══════════════════════════════════════════════════════════════
+14
View File
@@ -179,6 +179,20 @@ class MailLinkRequest(BaseModel):
company_id: str | None = None
class MailMoveRequest(BaseModel):
target_folder_id: str
class MailDraftCreate(BaseModel):
account_id: str
to: list[str] = Field(default_factory=list)
cc: list[str] = Field(default_factory=list)
bcc: list[str] = Field(default_factory=list)
subject: str = ""
body_text: str = ""
body_html: str = ""
class MailCreateEventRequest(BaseModel):
calendar_id: str
title: str | None = None
+463
View File
@@ -1403,3 +1403,466 @@ async def imap_sync_mail_flags(
await client.logout()
except Exception:
pass
# ─── IMAP Delete ───
async def imap_delete_mail(
db: AsyncSession, mail_id: uuid.UUID, tenant_id: uuid.UUID
) -> None:
"""Delete mail from IMAP server.
Connects to IMAP, selects the mail's folder, finds UID by Message-ID,
marks it as \\Deleted and expunges.
Non-critical: errors are logged, DB soft-delete still succeeds.
"""
import logging
logger = logging.getLogger(__name__)
mail = (
await db.execute(
select(Mail).where(
and_(Mail.id == mail_id, Mail.tenant_id == tenant_id)
)
)
).scalar_one_or_none()
if not mail:
logger.warning("imap_delete_mail: mail %s not found", mail_id)
return
folder = (
await db.execute(
select(MailFolder).where(
and_(MailFolder.id == mail.folder_id, MailFolder.tenant_id == tenant_id)
)
)
).scalar_one_or_none()
if not folder:
logger.warning("imap_delete_mail: folder %s not found for mail %s", mail.folder_id, mail_id)
return
account = (
await db.execute(
select(MailAccount).where(
and_(MailAccount.id == folder.account_id, MailAccount.tenant_id == tenant_id)
)
)
).scalar_one_or_none()
if not account:
logger.warning("imap_delete_mail: account not found for mail %s", mail_id)
return
if not mail.message_id:
logger.warning("imap_delete_mail: mail %s has no message_id, cannot sync", mail_id)
return
password = await get_account_password(account)
client = None
try:
client = aioimaplib.IMAP4_SSL(host=account.imap_host, port=account.imap_port)
await client.wait_hello_from_server()
await client.login(account.username, password)
select_resp = await client.select(folder.imap_name)
if select_resp.result != 'OK':
logger.warning("imap_delete_mail: cannot select folder %s", folder.imap_name)
return
search_resp = await client.uid_search(f'HEADER Message-ID "{mail.message_id}"')
uids_raw = search_resp[1][0] if search_resp[1] and search_resp[1][0] else b''
if isinstance(uids_raw, (bytes, bytearray)):
uids = uids_raw.split()
else:
uids = []
if not uids:
logger.warning("imap_delete_mail: no UID found for Message-ID %s", mail.message_id)
return
uid_str = uids[0].decode() if isinstance(uids[0], bytes) else str(uids[0])
await client.uid('store', uid_str, r'+FLAGS (\\Deleted)')
await client.expunge()
logger.info("imap_delete_mail: deleted mail %s (UID %s) from IMAP", mail_id, uid_str)
except Exception as exc:
logger.warning("imap_delete_mail: failed for mail %s: %s", mail_id, exc)
finally:
if client is not None:
try:
await client.logout()
except Exception:
pass
# ─── IMAP Move ───
async def imap_move_mail(
db: AsyncSession,
mail_id: uuid.UUID,
target_folder_id: uuid.UUID,
tenant_id: uuid.UUID,
) -> None:
"""Move mail to another folder on IMAP server.
Uses UID MOVE if supported, otherwise COPY + STORE \\Deleted + EXPUNGE.
Non-critical: errors are logged, DB update still succeeds.
"""
import logging
logger = logging.getLogger(__name__)
mail = (
await db.execute(
select(Mail).where(
and_(Mail.id == mail_id, Mail.tenant_id == tenant_id)
)
)
).scalar_one_or_none()
if not mail:
logger.warning("imap_move_mail: mail %s not found", mail_id)
return
source_folder = (
await db.execute(
select(MailFolder).where(
and_(MailFolder.id == mail.folder_id, MailFolder.tenant_id == tenant_id)
)
)
).scalar_one_or_none()
if not source_folder:
logger.warning("imap_move_mail: source folder not found for mail %s", mail_id)
return
target_folder = (
await db.execute(
select(MailFolder).where(
and_(MailFolder.id == target_folder_id, MailFolder.tenant_id == tenant_id)
)
)
).scalar_one_or_none()
if not target_folder:
logger.warning("imap_move_mail: target folder %s not found", target_folder_id)
return
account = (
await db.execute(
select(MailAccount).where(
and_(MailAccount.id == source_folder.account_id, MailAccount.tenant_id == tenant_id)
)
)
).scalar_one_or_none()
if not account:
logger.warning("imap_move_mail: account not found for mail %s", mail_id)
return
if not mail.message_id:
logger.warning("imap_move_mail: mail %s has no message_id, cannot sync", mail_id)
return
password = await get_account_password(account)
client = None
try:
client = aioimaplib.IMAP4_SSL(host=account.imap_host, port=account.imap_port)
await client.wait_hello_from_server()
await client.login(account.username, password)
select_resp = await client.select(source_folder.imap_name)
if select_resp.result != 'OK':
logger.warning("imap_move_mail: cannot select folder %s", source_folder.imap_name)
return
search_resp = await client.uid_search(f'HEADER Message-ID "{mail.message_id}"')
uids_raw = search_resp[1][0] if search_resp[1] and search_resp[1][0] else b''
if isinstance(uids_raw, (bytes, bytearray)):
uids = uids_raw.split()
else:
uids = []
if not uids:
logger.warning("imap_move_mail: no UID found for Message-ID %s", mail.message_id)
return
uid_str = uids[0].decode() if isinstance(uids[0], bytes) else str(uids[0])
# Try UID MOVE first; fall back to COPY + STORE \\Deleted + EXPUNGE
try:
move_resp = await client.uid('move', uid_str, target_folder.imap_name)
if move_resp.result == 'OK':
logger.info("imap_move_mail: moved mail %s (UID %s) via UID MOVE", mail_id, uid_str)
return
except Exception as move_exc:
logger.info("imap_move_mail: UID MOVE not supported, falling back: %s", move_exc)
# Fallback: COPY + STORE \\Deleted + EXPUNGE
copy_resp = await client.uid('copy', uid_str, target_folder.imap_name)
if copy_resp.result != 'OK':
logger.warning("imap_move_mail: COPY failed for mail %s", mail_id)
return
await client.uid('store', uid_str, r'+FLAGS (\\Deleted)')
await client.expunge()
logger.info("imap_move_mail: moved mail %s (UID %s) via COPY+DELETE", mail_id, uid_str)
except Exception as exc:
logger.warning("imap_move_mail: failed for mail %s: %s", mail_id, exc)
finally:
if client is not None:
try:
await client.logout()
except Exception:
pass
# ─── Draft Save / Update ───
async def save_draft(
db: AsyncSession,
account_id: uuid.UUID,
tenant_id: uuid.UUID,
user_id: uuid.UUID,
data: dict,
) -> Mail:
"""Save a new draft mail to DB and IMAP Drafts folder."""
import logging
logger = logging.getLogger(__name__)
# 1. Find the Drafts folder (imap_name contains 'Drafts')
account = (
await db.execute(
select(MailAccount).where(
and_(MailAccount.id == account_id, MailAccount.tenant_id == tenant_id)
)
)
).scalar_one_or_none()
if not account:
raise ValueError("Account not found")
folders = (
await db.execute(
select(MailFolder).where(
and_(MailFolder.account_id == account_id, MailFolder.tenant_id == tenant_id)
)
)
).scalars().all()
drafts_folder = None
for f in folders:
if 'draft' in f.imap_name.lower():
drafts_folder = f
break
if not drafts_folder:
raise ValueError("No Drafts folder found for this account")
# 2. Create Mail record
to_str = ', '.join(data.get('to', []))
cc_str = ', '.join(data.get('cc', []))
bcc_str = ', '.join(data.get('bcc', []))
subject = data.get('subject', '')
body_text = data.get('body_text', '')
body_html = data.get('body_html', '')
msg_id = make_msgid()
now = datetime.now(UTC)
mail = Mail(
tenant_id=tenant_id,
account_id=account_id,
folder_id=drafts_folder.id,
message_id=msg_id,
thread_id=msg_id,
subject=subject,
from_address=account.email_address,
to_addresses=to_str,
cc_addresses=cc_str,
bcc_addresses=bcc_str,
body_text=body_text,
body_html=body_html,
body_html_sanitized=body_html,
is_seen=True,
is_flagged=False,
is_draft=True,
is_answered=False,
is_forwarded=False,
has_attachments=False,
size_bytes=len(body_text.encode('utf-8')),
received_at=now,
sent_at=None,
)
db.add(mail)
await db.flush()
# 3. Build RFC822 message and APPEND to IMAP Drafts folder
password = await get_account_password(account)
client = None
try:
client = aioimaplib.IMAP4_SSL(host=account.imap_host, port=account.imap_port)
await client.wait_hello_from_server()
await client.login(account.username, password)
# Build RFC822 message
email_msg = EmailMessage()
email_msg['From'] = formataddr((account.display_name, account.email_address))
if to_str:
email_msg['To'] = to_str
if cc_str:
email_msg['Cc'] = cc_str
email_msg['Subject'] = subject
email_msg['Date'] = formatdate(localtime=True)
email_msg['Message-ID'] = msg_id
email_msg.set_content(body_text if body_text else '')
if body_html:
email_msg.add_alternative(body_html, subtype='html')
rfc822_bytes = email_msg.as_bytes()
# APPEND to Drafts folder
append_resp = await client.append(
drafts_folder.imap_name,
r'(\\Draft)',
str(int(now.timestamp())),
rfc822_bytes,
)
if append_resp.result != 'OK':
logger.warning("save_draft: IMAP APPEND failed for drafts folder %s", drafts_folder.imap_name)
else:
logger.info("save_draft: appended draft %s to IMAP Drafts", mail.id)
except Exception as exc:
logger.warning("save_draft: IMAP append failed (non-critical): %s", exc)
finally:
if client is not None:
try:
await client.logout()
except Exception:
pass
return mail
async def update_draft(
db: AsyncSession,
mail_id: uuid.UUID,
tenant_id: uuid.UUID,
data: dict,
) -> Mail:
"""Update an existing draft."""
import logging
logger = logging.getLogger(__name__)
mail = (
await db.execute(
select(Mail).where(
and_(Mail.id == mail_id, Mail.tenant_id == tenant_id)
)
)
).scalar_one_or_none()
if not mail:
raise ValueError("Mail not found")
if not mail.is_draft:
raise ValueError("Mail is not a draft")
# 2. Update fields
to_str = ', '.join(data.get('to', []))
cc_str = ', '.join(data.get('cc', []))
bcc_str = ', '.join(data.get('bcc', []))
mail.to_addresses = to_str
mail.cc_addresses = cc_str
mail.bcc_addresses = bcc_str
mail.subject = data.get('subject', '')
mail.body_text = data.get('body_text', '')
mail.body_html = data.get('body_html', '')
mail.body_html_sanitized = data.get('body_html', '')
mail.size_bytes = len(mail.body_text.encode('utf-8'))
await db.flush()
# 3. Delete old IMAP copy and APPEND new one
folder = (
await db.execute(
select(MailFolder).where(
and_(MailFolder.id == mail.folder_id, MailFolder.tenant_id == tenant_id)
)
)
).scalar_one_or_none()
if not folder:
return mail
account = (
await db.execute(
select(MailAccount).where(
and_(MailAccount.id == folder.account_id, MailAccount.tenant_id == tenant_id)
)
)
).scalar_one_or_none()
if not account:
return mail
password = await get_account_password(account)
client = None
try:
client = aioimaplib.IMAP4_SSL(host=account.imap_host, port=account.imap_port)
await client.wait_hello_from_server()
await client.login(account.username, password)
# Delete old copy from IMAP
select_resp = await client.select(folder.imap_name)
if select_resp.result == 'OK' and mail.message_id:
search_resp = await client.uid_search(f'HEADER Message-ID "{mail.message_id}"')
uids_raw = search_resp[1][0] if search_resp[1] and search_resp[1][0] else b''
if isinstance(uids_raw, (bytes, bytearray)):
uids = uids_raw.split()
else:
uids = []
if uids:
uid_str = uids[0].decode() if isinstance(uids[0], bytes) else str(uids[0])
await client.uid('store', uid_str, r'+FLAGS (\\Deleted)')
await client.expunge()
# APPEND new copy
email_msg = EmailMessage()
email_msg['From'] = formataddr((account.display_name, account.email_address))
if to_str:
email_msg['To'] = to_str
if cc_str:
email_msg['Cc'] = cc_str
email_msg['Subject'] = mail.subject
email_msg['Date'] = formatdate(localtime=True)
email_msg['Message-ID'] = mail.message_id
email_msg.set_content(mail.body_text if mail.body_text else '')
if mail.body_html:
email_msg.add_alternative(mail.body_html, subtype='html')
rfc822_bytes = email_msg.as_bytes()
now = datetime.now(UTC)
append_resp = await client.append(
folder.imap_name,
r'(\\Draft)',
str(int(now.timestamp())),
rfc822_bytes,
)
if append_resp.result != 'OK':
logger.warning("update_draft: IMAP APPEND failed for drafts folder %s", folder.imap_name)
else:
logger.info("update_draft: appended updated draft %s to IMAP Drafts", mail.id)
except Exception as exc:
logger.warning("update_draft: IMAP sync failed (non-critical): %s", exc)
finally:
if client is not None:
try:
await client.logout()
except Exception:
pass
return mail