diff --git a/app/plugins/builtins/mail/plugin.py b/app/plugins/builtins/mail/plugin.py index 3caa934..d2350b2 100644 --- a/app/plugins/builtins/mail/plugin.py +++ b/app/plugins/builtins/mail/plugin.py @@ -29,7 +29,7 @@ async def _auto_sync_loop() -> None: await auto_sync_all_accounts() except Exception as exc: logger.warning("auto_sync error: %s", exc) - await asyncio.sleep(300) + await asyncio.sleep(60) class MailPlugin(BasePlugin): diff --git a/app/plugins/builtins/mail/routes.py b/app/plugins/builtins/mail/routes.py index bdc1aa7..7756292 100644 --- a/app/plugins/builtins/mail/routes.py +++ b/app/plugins/builtins/mail/routes.py @@ -632,6 +632,32 @@ async def empty_folder( return {"emptied_count": len(mails)} +# ─── Sync single folder (live sync when selecting a folder) ─── + + +@router.post("/folders/{folder_id}/sync") +async def sync_folder( + folder_id: str, + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(require_permission("mail:read")), +): + """Sync a single folder from IMAP server immediately.""" + tenant_id = uuid.UUID(current_user["tenant_id"]) + user_id = uuid.UUID(current_user["user_id"]) + f_id = _parse_uuid(folder_id, "folder_id") + folder = ( + await db.execute( + select(MailFolder).where(and_(MailFolder.id == f_id, MailFolder.tenant_id == tenant_id)) + ) + ).scalar_one_or_none() + if not folder: + raise HTTPException(404, detail={"detail": "Folder not found", "code": "not_found"}) + account = await _get_account(db, folder.account_id, tenant_id, user_id) + result = await mail_services.imap_sync_folder(db, f_id, tenant_id) + await db.flush() + return result + + # ─── Attachment Upload (F-MAIL-04) ─── diff --git a/app/plugins/builtins/mail/services.py b/app/plugins/builtins/mail/services.py index 003f90d..7c3e54b 100644 --- a/app/plugins/builtins/mail/services.py +++ b/app/plugins/builtins/mail/services.py @@ -536,6 +536,226 @@ def _build_folder_hierarchy( return result +async def imap_sync_folder( + db: AsyncSession, + folder_id: uuid.UUID, + tenant_id: uuid.UUID, +) -> dict: + """Sync a single folder from IMAP server — fast, for live use when selecting a folder.""" + import logging + logger = logging.getLogger(__name__) + + folder = ( + await db.execute( + select(MailFolder).where( + and_(MailFolder.id == folder_id, MailFolder.tenant_id == tenant_id) + ) + ) + ).scalar_one_or_none() + if not folder: + return {"synced": 0, "error": "Folder not found"} + + 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 {"synced": 0, "error": "Account not found"} + + if not account.is_active: + return {"synced": 0, "error": "Account is not active"} + + 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': + return {"synced": 0, "error": f"Cannot select folder {folder.imap_name}"} + + # Fetch UIDs — get last MAX_EMAILS_PER_FOLDER + search_resp = await client.uid_search('ALL') + 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 len(uids) > MAX_EMAILS_PER_FOLDER: + uids = uids[-MAX_EMAILS_PER_FOLDER:] + + synced_count = 0 + for uid in uids: + uid_str = uid.decode() if isinstance(uid, bytes) else str(uid) + + # Check if we already have this UID + existing_mail = ( + await db.execute( + select(Mail).where( + and_( + Mail.account_id == account.id, + Mail.folder_id == folder.id, + Mail.imap_uid == uid_str, + Mail.tenant_id == tenant_id, + ) + ) + ) + ).scalar_one_or_none() + + if existing_mail: + # Restore soft-deleted mails if they still exist on IMAP + if existing_mail.deleted_at is not None: + existing_mail.deleted_at = None + logger.info("imap_sync_folder: restored soft-deleted mail %s", existing_mail.id) + continue + + # Fetch and parse the email + fetch_resp = await client.uid('fetch', uid_str, '(RFC822)') + raw_email = None + for line in (fetch_resp.lines if hasattr(fetch_resp, 'lines') else fetch_resp): + if isinstance(line, bytearray): + raw_email = bytes(line) + break + if raw_email is None: + continue + if isinstance(raw_email, str): + raw_email = raw_email.encode() + + msg = message_from_bytes(raw_email) + body_text = "" + body_html = "" + attachments = [] + + if msg.is_multipart(): + for part in msg.walk(): + ct = part.get_content_type() + if ct == "text/plain": + payload = part.get_payload(decode=True) + if payload: + body_text = payload.decode("utf-8", errors="replace") + elif ct == "text/html": + payload = part.get_payload(decode=True) + if payload: + body_html = payload.decode("utf-8", errors="replace") + elif part.get_filename(): + payload_bytes = part.get_payload(decode=True) or b"" + attachments.append({ + "filename": part.get_filename(), + "mime_type": ct, + "size": len(payload_bytes), + "content": payload_bytes, + }) + else: + ct = msg.get_content_type() + payload = msg.get_payload(decode=True) + if payload: + decoded = payload.decode("utf-8", errors="replace") + if ct == "text/html": + body_html = decoded + else: + body_text = decoded + + from email.header import decode_header, make_header + + def _decode_mime_header(value: str) -> str: + if not value: + return "" + try: + return str(make_header(decode_header(value))) + except Exception: + return value + + raw_msg_id = msg.get("Message-ID") + if raw_msg_id: + message_id = raw_msg_id + else: + message_id = f"generated-{account.id}-{folder.id}-{uid_str}" + subject = _decode_mime_header(msg.get("Subject", "")) + from_addr = _decode_mime_header(msg.get("From", "")) + to_addrs = _decode_mime_header(msg.get("To", "")) + cc_addrs = _decode_mime_header(msg.get("Cc", "")) + refs = msg.get("References", "") + in_reply_to = msg.get("In-Reply-To") + date_str = msg.get("Date", "") + + received_at = datetime.now(UTC) + if date_str: + try: + from email.utils import parsedate_to_datetime + parsed = parsedate_to_datetime(date_str) + if parsed: + received_at = parsed.astimezone(UTC) if parsed.tzinfo else parsed.replace(tzinfo=UTC) + except Exception: + pass + + thread_id = _compute_thread_id(message_id, refs, in_reply_to) + + mail = Mail( + tenant_id=tenant_id, + account_id=account.id, + folder_id=folder.id, + imap_uid=uid_str, + message_id=message_id, + thread_id=thread_id, + in_reply_to=in_reply_to, + references_header=refs, + subject=subject, + from_address=from_addr, + to_addresses=to_addrs, + cc_addresses=cc_addrs, + body_text=body_text, + body_html=body_html, + body_html_sanitized=sanitize_html(body_html), + has_attachments=len(attachments) > 0, + size_bytes=len(raw_email), + received_at=received_at, + ) + db.add(mail) + await db.flush() + synced_count += 1 + + # Save attachments + for att_data in attachments: + try: + raw_filename = att_data["filename"] or "attachment" + decoded_filename = _decode_mime_filename(raw_filename) + storage_path = await _save_attachment_to_storage( + mail.id, decoded_filename, att_data["content"] + ) + attachment = MailAttachment( + tenant_id=tenant_id, + mail_id=mail.id, + filename=_sanitize_filename(decoded_filename), + mime_type=att_data["mime_type"], + size_bytes=att_data["size"], + storage_path=storage_path, + ) + db.add(attachment) + except Exception as att_err: + logger.warning(f"Failed to save attachment for mail {mail.id}: {att_err}") + continue + + await db.flush() + logger.info("imap_sync_folder: synced %d new mail(s) for folder %s", synced_count, folder.imap_name) + return {"synced": synced_count} + + except Exception as exc: + logger.warning("imap_sync_folder: failed for folder %s: %s", folder_id, exc) + return {"synced": 0, "error": str(exc)} + finally: + if client is not None: + try: + await client.logout() + except Exception: + pass + + async def imap_sync_account( db: AsyncSession, account_id: uuid.UUID, diff --git a/frontend/src/api/mail.ts b/frontend/src/api/mail.ts index ba24594..88ec7cf 100644 --- a/frontend/src/api/mail.ts +++ b/frontend/src/api/mail.ts @@ -481,6 +481,10 @@ export function emptyFolder(folderId: string): Promise<{ emptied_count: number } return apiPost<{ emptied_count: number }>(`/mail/folders/${folderId}/empty`, {}); } +export function syncFolder(folderId: string): Promise<{ synced: number; error?: string }> { + return apiPost<{ synced: number; error?: string }>(`/mail/folders/${folderId}/sync`, {}); +} + // ─── Mails ────────────────────────────────────────────────────────────────── export function fetchMails(folderId: string, page: number, sortBy?: string, sortOrder?: string): Promise { diff --git a/frontend/src/pages/Mail.tsx b/frontend/src/pages/Mail.tsx index 2039abc..2fc2661 100644 --- a/frontend/src/pages/Mail.tsx +++ b/frontend/src/pages/Mail.tsx @@ -35,6 +35,7 @@ import { saveDraft, updateDraft, triggerSync, + syncFolder, type MailAccount, type MailFolder, type Mail, @@ -168,7 +169,7 @@ export function MailPage() { loadMails(); }, [loadMails]); - // Handle folder selection — derive account from folder + // Handle folder selection — derive account from folder + trigger live IMAP sync const handleSelectFolder = useCallback( (folderId: string) => { setSelectedFolderId(folderId); @@ -181,8 +182,13 @@ export function MailPage() { setSelectedMail(null); setSelectedMailIds(new Set()); setActiveView('list'); + // Live sync: fetch new mails from IMAP server immediately + syncFolder(folderId).then(() => { + loadMails(); + loadAllFolders(); + }).catch(() => {}); }, - [folders], + [folders, loadMails, loadAllFolders], ); // Handle mail selection