From ed1eec87dc9201b9ff1952539b2c6464dbee5d9d Mon Sep 17 00:00:00 2001 From: Agent Zero Date: Wed, 15 Jul 2026 19:44:41 +0200 Subject: [PATCH] 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) --- app/plugins/builtins/mail/models.py | 3 + app/plugins/builtins/mail/routes.py | 100 ++++ app/plugins/builtins/mail/schemas.py | 14 + app/plugins/builtins/mail/services.py | 463 ++++++++++++++++++ frontend/src/api/mail.ts | 29 ++ frontend/src/components/mail/ComposeModal.tsx | 53 +- frontend/src/components/mail/MailDetail.tsx | 74 ++- frontend/src/i18n/locales/de.json | 15 +- frontend/src/i18n/locales/en.json | 15 +- frontend/src/pages/Mail.tsx | 205 +++++++- 10 files changed, 950 insertions(+), 21 deletions(-) diff --git a/app/plugins/builtins/mail/models.py b/app/plugins/builtins/mail/models.py index 98ab36e..52e66dc 100644 --- a/app/plugins/builtins/mail/models.py +++ b/app/plugins/builtins/mail/models.py @@ -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) --- diff --git a/app/plugins/builtins/mail/routes.py b/app/plugins/builtins/mail/routes.py index 046af71..793c8e6 100644 --- a/app/plugins/builtins/mail/routes.py +++ b/app/plugins/builtins/mail/routes.py @@ -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 # ═══════════════════════════════════════════════════════════════ diff --git a/app/plugins/builtins/mail/schemas.py b/app/plugins/builtins/mail/schemas.py index a3970d3..df815e2 100644 --- a/app/plugins/builtins/mail/schemas.py +++ b/app/plugins/builtins/mail/schemas.py @@ -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 diff --git a/app/plugins/builtins/mail/services.py b/app/plugins/builtins/mail/services.py index b5540e8..70af4e7 100644 --- a/app/plugins/builtins/mail/services.py +++ b/app/plugins/builtins/mail/services.py @@ -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 diff --git a/frontend/src/api/mail.ts b/frontend/src/api/mail.ts index 5d72017..7194d3d 100644 --- a/frontend/src/api/mail.ts +++ b/frontend/src/api/mail.ts @@ -61,6 +61,7 @@ export interface Mail { date: string; is_seen: boolean; is_flagged: boolean; + is_draft: boolean; is_answered: boolean; has_attachments: boolean; attachments?: MailAttachment[]; @@ -573,3 +574,31 @@ export function fetchLabels(): Promise { export function deleteLabel(labelId: string): Promise { return apiDelete(`/mail/labels/${labelId}`); } + +// ─── Delete / Move / Drafts ────────────────────────────────────────────────── + +export function deleteMail(mailId: string): Promise { + return apiDelete(`/mail/${mailId}`); +} + +export function moveMail(mailId: string, targetFolderId: string): Promise { + return apiPost(`/mail/${mailId}/move`, { target_folder_id: targetFolderId }); +} + +export interface MailDraftPayload { + account_id: string; + to: string[]; + cc: string[]; + bcc: string[]; + subject: string; + body_text: string; + body_html: string; +} + +export function saveDraft(payload: MailDraftPayload): Promise { + return apiPost('/mail/drafts', payload); +} + +export function updateDraft(mailId: string, payload: MailDraftPayload): Promise { + return apiPatch(`/mail/drafts/${mailId}`, payload); +} diff --git a/frontend/src/components/mail/ComposeModal.tsx b/frontend/src/components/mail/ComposeModal.tsx index 43a6a1d..65e145d 100644 --- a/frontend/src/components/mail/ComposeModal.tsx +++ b/frontend/src/components/mail/ComposeModal.tsx @@ -10,10 +10,10 @@ import { Button } from '@/components/ui/Button'; import { Input } from '@/components/ui/Input'; import { Select } from '@/components/ui/Select'; import { TemplatePicker } from './TemplatePicker'; -import type { Mail, MailSignature, SendMailPayload, ReplyPayload, ForwardPayload } from '@/api/mail'; +import type { Mail, MailSignature, SendMailPayload, ReplyPayload, ForwardPayload, MailDraftPayload } from '@/api/mail'; import { uploadAttachment, type UploadedAttachment } from '@/api/mail'; -export type ComposeMode = 'new' | 'reply' | 'forward'; +export type ComposeMode = 'new' | 'reply' | 'forward' | 'draft'; export interface ComposeModalProps { open: boolean; @@ -21,8 +21,10 @@ export interface ComposeModalProps { accountId: string; replyToMail: Mail | null; forwardMail: Mail | null; + draftMail?: Mail | null; signatures: MailSignature[]; onSend: (payload: SendMailPayload | ReplyPayload | ForwardPayload, mode: ComposeMode) => Promise; + onSaveDraft?: (payload: MailDraftPayload) => Promise; onClose: () => void; } @@ -32,8 +34,10 @@ export function ComposeModal({ accountId, replyToMail, forwardMail, + draftMail, signatures, onSend, + onSaveDraft, onClose, }: ComposeModalProps) { const { t } = useTranslation(); @@ -45,6 +49,7 @@ export function ComposeModal({ const [body, setBody] = useState(''); const [showCc, setShowCc] = useState(false); const [sending, setSending] = useState(false); + const [savingDraft, setSavingDraft] = useState(false); const [selectedSignatureId, setSelectedSignatureId] = useState(''); const [showTemplatePicker, setShowTemplatePicker] = useState(false); const [attachments, setAttachments] = useState([]); @@ -61,6 +66,13 @@ export function ComposeModal({ setTo(''); setSubject(forwardMail.subject.startsWith('Fwd: ') ? forwardMail.subject : `Fwd: ${forwardMail.subject}`); setBody(`\n\n---\n${t('mail.forwarding')}\n${t('mail.from')}: ${forwardMail.from_address}\n${t('mail.subject')}: ${forwardMail.subject}\n\n${forwardMail.body_text.slice(0, 200)}`); + } else if (mode === 'draft' && draftMail) { + setTo(draftMail.to_addresses.join(', ')); + setCc(draftMail.cc_addresses.join(', ')); + setBcc(draftMail.bcc_addresses.join(', ')); + setSubject(draftMail.subject); + setBody(draftMail.body_html || draftMail.body_text || ''); + setShowCc(draftMail.cc_addresses.length > 0 || draftMail.bcc_addresses.length > 0); } else { setTo(''); setCc(''); @@ -69,7 +81,7 @@ export function ComposeModal({ setBody(''); setAttachments([]); } - }, [open, mode, replyToMail, forwardMail, t]); + }, [open, mode, replyToMail, forwardMail, draftMail, t]); const execCommand = useCallback((command: string, value?: string) => { document.execCommand(command, false, value); @@ -142,6 +154,28 @@ export function ComposeModal({ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; }; + const handleSaveDraft = useCallback(async () => { + if (!onSaveDraft) return; + setSavingDraft(true); + try { + const toList = to.split(',').map((s) => s.trim()).filter(Boolean); + const ccList = cc ? cc.split(',').map((s) => s.trim()).filter(Boolean) : []; + const bccList = bcc ? bcc.split(',').map((s) => s.trim()).filter(Boolean) : []; + const payload: MailDraftPayload = { + account_id: accountId, + to: toList, + cc: ccList, + bcc: bccList, + subject, + body_text: body.replace(/<[^>]*>/g, ''), + body_html: body, + }; + await onSaveDraft(payload); + } finally { + setSavingDraft(false); + } + }, [to, cc, bcc, subject, body, accountId, onSaveDraft]); + const handleSend = useCallback(async () => { if (!to.trim()) return; setSending(true); @@ -190,7 +224,7 @@ export function ComposeModal({ } }, [to, cc, bcc, subject, body, mode, replyToMail, forwardMail, accountId, selectedSignatureId, attachments, onSend, onClose]); - const title = mode === 'reply' ? t('mail.reply') : mode === 'forward' ? t('mail.forward') : t('mail.compose'); + const title = mode === 'reply' ? t('mail.reply') : mode === 'forward' ? t('mail.forward') : mode === 'draft' ? t('mail.editDraft') : t('mail.compose'); return ( @@ -379,6 +413,17 @@ export function ComposeModal({ + {onSaveDraft && ( + + )} diff --git a/frontend/src/components/mail/MailDetail.tsx b/frontend/src/components/mail/MailDetail.tsx index 85fa77e..945400d 100644 --- a/frontend/src/components/mail/MailDetail.tsx +++ b/frontend/src/components/mail/MailDetail.tsx @@ -19,6 +19,9 @@ export interface MailDetailProps { onToggleFlag: (mail: Mail) => void; onDownloadAttachment: (mailId: string, attachment: MailAttachment) => void; downloadingAttachmentId: string | null; + onDelete?: (mail: Mail) => void; + onMove?: (mail: Mail) => void; + onEditDraft?: (mail: Mail) => void; } function formatFullDate(dateStr: string): string { @@ -47,6 +50,9 @@ export function MailDetail({ onToggleFlag, onDownloadAttachment, downloadingAttachmentId, + onDelete, + onMove, + onEditDraft, }: MailDetailProps) { const { t } = useTranslation(); @@ -98,20 +104,32 @@ export function MailDetail({
{/* Toolbar */}
- - + {mail.is_draft && onEditDraft ? ( + + ) : ( + <> + + + + )} + {onMove && ( + + )}
+ {onDelete && ( + + )}
{/* Headers */} diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json index 27372c4..9d8784c 100644 --- a/frontend/src/i18n/locales/de.json +++ b/frontend/src/i18n/locales/de.json @@ -540,6 +540,19 @@ "markUnread": "Als ungelesen markieren", "selectAll": "Alle auswählen", "bulkActions": "Sammelaktionen", - "selectedCount": "{{count}} ausgewählt" + "selectedCount": "{{count}} ausgewählt", + "delete": "Löschen", + "deleteConfirm": "Möchten Sie diese E-Mail wirklich löschen?", + "deleteMail": "E-Mail löschen", + "bulkDelete": "Alle löschen", + "bulkDeleteConfirm": "Möchten Sie die ausgewählten E-Mails wirklich löschen?", + "move": "Verschieben", + "moveTo": "Verschieben nach", + "moveMail": "E-Mail verschieben", + "bulkMove": "Alle verschieben", + "saveDraft": "Entwurf speichern", + "editDraft": "Entwurf bearbeiten", + "draftSaved": "Entwurf gespeichert.", + "draft": "Entwurf" } } diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 8ab73ca..5660e37 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -540,6 +540,19 @@ "markUnread": "Mark as Unread", "selectAll": "Select All", "bulkActions": "Bulk Actions", - "selectedCount": "{{count}} selected" + "selectedCount": "{{count}} selected", + "delete": "Delete", + "deleteConfirm": "Are you sure you want to delete this email?", + "deleteMail": "Delete Email", + "bulkDelete": "Delete All", + "bulkDeleteConfirm": "Are you sure you want to delete the selected emails?", + "move": "Move", + "moveTo": "Move to", + "moveMail": "Move Email", + "bulkMove": "Move All", + "saveDraft": "Save Draft", + "editDraft": "Edit Draft", + "draftSaved": "Draft saved.", + "draft": "Draft" } } diff --git a/frontend/src/pages/Mail.tsx b/frontend/src/pages/Mail.tsx index 193958f..61df038 100644 --- a/frontend/src/pages/Mail.tsx +++ b/frontend/src/pages/Mail.tsx @@ -32,6 +32,10 @@ import { downloadAttachment, fetchSignatures, searchMails, + deleteMail, + moveMail, + saveDraft, + updateDraft, type MailAccount, type MailFolder, type Mail, @@ -41,6 +45,7 @@ import { type ReplyPayload, type ForwardPayload, type CreateEventFromMailPayload, + type MailDraftPayload, } from '@/api/mail'; const PAGE_SIZE = 25; @@ -67,10 +72,12 @@ export function MailPage() { const [composeMode, setComposeMode] = useState('new'); const [replyToMail, setReplyToMail] = useState(null); const [forwardMailState, setForwardMailState] = useState(null); + const [draftMailState, setDraftMailState] = useState(null); const [downloadingAttachmentId, setDownloadingAttachmentId] = useState(null); const [searchQuery, setSearchQuery] = useState(''); const [activeView, setActiveView] = useState<'folders' | 'list' | 'detail'>('folders'); const [selectedMailIds, setSelectedMailIds] = useState>(new Set()); + const [showMoveDropdown, setShowMoveDropdown] = useState(false); // Load accounts const loadAccounts = useCallback(async () => { @@ -204,6 +211,7 @@ export function MailPage() { setComposeMode('new'); setReplyToMail(null); setForwardMailState(null); + setDraftMailState(null); setComposeOpen(true); }, []); @@ -212,6 +220,7 @@ export function MailPage() { setComposeMode('reply'); setReplyToMail(mail); setForwardMailState(null); + setDraftMailState(null); setComposeOpen(true); }, []); @@ -220,6 +229,98 @@ export function MailPage() { setComposeMode('forward'); setForwardMailState(mail); setReplyToMail(null); + setDraftMailState(null); + setComposeOpen(true); + }, []); + + // Handle delete single mail + const handleDeleteMail = useCallback( + async (mail: Mail) => { + if (!window.confirm(t('mail.deleteConfirm'))) return; + try { + await deleteMail(mail.id); + setMails((prev) => prev.filter((m) => m.id !== mail.id)); + setSelectedMail(null); + toast.success(t('mail.delete')); + } catch (err) { + toast.error(err instanceof Error ? err.message : String(err)); + } + }, + [toast, t], + ); + + // Handle bulk delete + const handleBulkDelete = useCallback(async () => { + if (!window.confirm(t('mail.bulkDeleteConfirm'))) return; + const ids = Array.from(selectedMailIds); + try { + await Promise.all(ids.map((id) => deleteMail(id))); + setMails((prev) => prev.filter((m) => !selectedMailIds.has(m.id))); + setSelectedMailIds(new Set()); + setSelectedMail(null); + toast.success(t('mail.delete')); + } catch (err) { + toast.error(err instanceof Error ? err.message : String(err)); + } + }, [selectedMailIds, toast, t]); + + // Handle move single mail + const handleMoveMail = useCallback( + async (mail: Mail, targetFolderId: string) => { + try { + await moveMail(mail.id, targetFolderId); + setMails((prev) => prev.filter((m) => m.id !== mail.id)); + setSelectedMail(null); + setShowMoveDropdown(false); + toast.success(t('mail.move')); + } catch (err) { + toast.error(err instanceof Error ? err.message : String(err)); + } + }, + [toast, t], + ); + + // Handle bulk move + const handleBulkMove = useCallback( + async (targetFolderId: string) => { + const ids = Array.from(selectedMailIds); + try { + await Promise.all(ids.map((id) => moveMail(id, targetFolderId))); + setMails((prev) => prev.filter((m) => !selectedMailIds.has(m.id))); + setSelectedMailIds(new Set()); + setShowMoveDropdown(false); + toast.success(t('mail.move')); + } catch (err) { + toast.error(err instanceof Error ? err.message : String(err)); + } + }, + [selectedMailIds, toast, t], + ); + + // Handle save draft (new or existing) + const handleSaveDraft = useCallback( + async (payload: MailDraftPayload) => { + try { + if (composeMode === 'draft' && draftMailState) { + await updateDraft(draftMailState.id, payload); + } else { + await saveDraft(payload); + } + toast.success(t('mail.draftSaved')); + } catch (err) { + toast.error(err instanceof Error ? err.message : String(err)); + throw err; + } + }, + [composeMode, draftMailState, toast, t], + ); + + // Handle edit draft + const handleEditDraft = useCallback((mail: Mail) => { + setComposeMode('draft'); + setDraftMailState(mail); + setReplyToMail(null); + setForwardMailState(null); setComposeOpen(true); }, []); @@ -453,6 +554,32 @@ export function MailPage() { ), onClick: () => selectedMail && handleCreateEvent(selectedMail), }, + { + id: 'delete', + plugin: 'mail', + label: t('mail.delete'), + group: 'mail-actions', + disabled: !hasMail, + icon: ( + + + + ), + onClick: () => selectedMail && handleDeleteMail(selectedMail), + }, + { + id: 'move', + plugin: 'mail', + label: t('mail.move'), + group: 'mail-actions', + disabled: !hasMail, + icon: ( + + + + ), + onClick: () => setShowMoveDropdown((v) => !v), + }, { id: 'sync', plugin: 'mail', @@ -492,11 +619,35 @@ export function MailPage() { ), onClick: handleBulkMarkUnread, }, + { + id: 'bulk-delete', + plugin: 'mail', + label: t('mail.bulkDelete'), + group: 'bulk-actions', + icon: ( + + + + ), + onClick: handleBulkDelete, + }, + { + id: 'bulk-move', + plugin: 'mail', + label: t('mail.bulkMove'), + group: 'bulk-actions', + icon: ( + + + + ), + onClick: () => setShowMoveDropdown((v) => !v), + }, ); } registerItems('mail', items); return () => unregisterPlugin('mail'); - }, [selectedMailId, selectedMailFlagged, selectedMailIds, handleSearch, handleCompose, handleReply, handleForward, handleToggleFlag, handleCreateEvent, handleBulkMarkRead, handleBulkMarkUnread, t]); + }, [selectedMailId, selectedMailFlagged, selectedMailIds, handleSearch, handleCompose, handleReply, handleForward, handleToggleFlag, handleCreateEvent, handleDeleteMail, handleBulkDelete, handleBulkMarkRead, handleBulkMarkUnread, t]); if (loadingAccounts) { return ( @@ -589,6 +740,9 @@ export function MailPage() { onToggleFlag={handleToggleFlag} onDownloadAttachment={handleDownloadAttachment} downloadingAttachmentId={downloadingAttachmentId} + onDelete={handleDeleteMail} + onMove={(mail) => setShowMoveDropdown(true)} + onEditDraft={handleEditDraft} />
@@ -667,6 +821,9 @@ export function MailPage() { onToggleFlag={handleToggleFlag} onDownloadAttachment={handleDownloadAttachment} downloadingAttachmentId={downloadingAttachmentId} + onDelete={handleDeleteMail} + onMove={(mail) => setShowMoveDropdown(true)} + onEditDraft={handleEditDraft} />
)} @@ -688,20 +845,66 @@ export function MailPage() { + +
+ + {showMoveDropdown && ( +
+ {folders.map((folder) => ( + + ))} +
+ )} +
)} + {/* Move dropdown for single mail */} + {showMoveDropdown && selectedMail && selectedMailIds.size === 0 && ( +
+
+ {t('mail.moveTo')} +
+ {folders + .filter((f) => f.account_id === selectedMail.account_id) + .map((folder) => ( + + ))} +
+ )} + setComposeOpen(false)} />