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