feat: live IMAP sync on folder select + 60s auto-sync interval
- New imap_sync_folder function: syncs single folder quickly for live use
- New POST /mail/folders/{folder_id}/sync route
- Frontend: syncFolder API + called on every folder selection
- Auto-sync interval reduced from 300s to 60s
- Sent mail IMAP APPEND already uploads full body via msg.as_bytes()
This commit is contained in:
@@ -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):
|
||||
|
||||
@@ -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) ───
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<MailListResult> {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user