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()
|
await auto_sync_all_accounts()
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.warning("auto_sync error: %s", exc)
|
logger.warning("auto_sync error: %s", exc)
|
||||||
await asyncio.sleep(300)
|
await asyncio.sleep(60)
|
||||||
|
|
||||||
|
|
||||||
class MailPlugin(BasePlugin):
|
class MailPlugin(BasePlugin):
|
||||||
|
|||||||
@@ -632,6 +632,32 @@ async def empty_folder(
|
|||||||
return {"emptied_count": len(mails)}
|
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) ───
|
# ─── Attachment Upload (F-MAIL-04) ───
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -536,6 +536,226 @@ def _build_folder_hierarchy(
|
|||||||
return result
|
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(
|
async def imap_sync_account(
|
||||||
db: AsyncSession,
|
db: AsyncSession,
|
||||||
account_id: uuid.UUID,
|
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`, {});
|
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 ──────────────────────────────────────────────────────────────────
|
// ─── Mails ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export function fetchMails(folderId: string, page: number, sortBy?: string, sortOrder?: string): Promise<MailListResult> {
|
export function fetchMails(folderId: string, page: number, sortBy?: string, sortOrder?: string): Promise<MailListResult> {
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ import {
|
|||||||
saveDraft,
|
saveDraft,
|
||||||
updateDraft,
|
updateDraft,
|
||||||
triggerSync,
|
triggerSync,
|
||||||
|
syncFolder,
|
||||||
type MailAccount,
|
type MailAccount,
|
||||||
type MailFolder,
|
type MailFolder,
|
||||||
type Mail,
|
type Mail,
|
||||||
@@ -168,7 +169,7 @@ export function MailPage() {
|
|||||||
loadMails();
|
loadMails();
|
||||||
}, [loadMails]);
|
}, [loadMails]);
|
||||||
|
|
||||||
// Handle folder selection — derive account from folder
|
// Handle folder selection — derive account from folder + trigger live IMAP sync
|
||||||
const handleSelectFolder = useCallback(
|
const handleSelectFolder = useCallback(
|
||||||
(folderId: string) => {
|
(folderId: string) => {
|
||||||
setSelectedFolderId(folderId);
|
setSelectedFolderId(folderId);
|
||||||
@@ -181,8 +182,13 @@ export function MailPage() {
|
|||||||
setSelectedMail(null);
|
setSelectedMail(null);
|
||||||
setSelectedMailIds(new Set());
|
setSelectedMailIds(new Set());
|
||||||
setActiveView('list');
|
setActiveView('list');
|
||||||
|
// Live sync: fetch new mails from IMAP server immediately
|
||||||
|
syncFolder(folderId).then(() => {
|
||||||
|
loadMails();
|
||||||
|
loadAllFolders();
|
||||||
|
}).catch(() => {});
|
||||||
},
|
},
|
||||||
[folders],
|
[folders, loadMails, loadAllFolders],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Handle mail selection
|
// Handle mail selection
|
||||||
|
|||||||
Reference in New Issue
Block a user