diff --git a/crates/core/src/account/state.rs b/crates/core/src/account/state.rs index 3fd606f..af81878 100644 --- a/crates/core/src/account/state.rs +++ b/crates/core/src/account/state.rs @@ -67,6 +67,64 @@ pub struct FolderProgress { pub message: Option, } +#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize)] +#[cfg_attr(feature = "web-api", derive(poem_openapi::Enum))] +pub enum GapFillStatus { + #[default] + Running, + Success, + Failed, + Cancelled, +} + +#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize)] +#[cfg_attr(feature = "web-api", derive(poem_openapi::Object))] +pub struct GapFillFolderStats { + pub downloaded: u64, + pub failed: u64, + pub candidate_count: u64, + /// Live progress hint (e.g. "IMAP server is slow...") shown while the + /// folder is being scanned; usually `None` once the folder is done. + pub message: Option, +} + +#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize)] +#[cfg_attr(feature = "web-api", derive(poem_openapi::Object))] +pub struct GapFillRun { + pub started_at: i64, + pub finished_at: Option, + pub status: GapFillStatus, + /// Per-mailbox gap-fill outcome, keyed by mailbox name. + pub folders: BTreeMap, + /// Total emails newly downloaded by gap-fill. + pub downloaded: u64, + /// Total emails that failed to download during gap-fill. + pub failed: u64, +} + +/// Independent, repeatable gap-fill history for an account. Gap-fill is a +/// distinct operation from downloading (it can be run again and again until +/// `failed == 0`), so its runs are tracked separately from `DownloadState` +/// instead of being mixed into download sessions. +#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize)] +#[cfg_attr(feature = "web-api", derive(poem_openapi::Object))] +pub struct GapFillState { + pub account_id: u64, + /// The gap-fill run currently in progress, if any. + pub active: Option, + /// Finished runs, most recent last. + pub history: Vec, +} + +impl MemDbModel for GapFillState { + fn collection() -> &'static str { + "gap_fill_states" + } + fn key(&self) -> String { + self.account_id.to_string() + } +} + #[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize)] #[cfg_attr(feature = "web-api", derive(poem_openapi::Object))] pub struct DownloadSession { @@ -321,6 +379,18 @@ impl DownloadState { }) } + /// Appends/updates a free-form message on the active session without + /// changing its status. Used to record the gap-fill summary. + pub fn update_session_message(account_id: u64, message: String) -> BichonResult<()> { + Self::update_state(account_id, move |current| { + let mut updated = current.clone(); + if let Some(ref mut session) = updated.active_session { + session.message = Some(message); + } + Ok(updated) + }) + } + fn update_state( account_id: u64, updater: impl FnOnce(DownloadState) -> BichonResult + Send + 'static, @@ -339,3 +409,161 @@ impl DownloadState { delete_impl::(DB_MANAGER.db(), &account_id.to_string()) } } + +impl GapFillState { + pub fn get(account_id: u64) -> BichonResult> { + find_impl::(DB_MANAGER.db(), &account_id.to_string()) + } + + /// Starts a new gap-fill run, moving any stale active run into history as + /// Cancelled. Creates the state record on first use. + pub fn start_run(account_id: u64) -> BichonResult<()> { + let now = utc_now!(); + let run = GapFillRun { + started_at: now, + status: GapFillStatus::Running, + ..Default::default() + }; + if Self::get(account_id)?.is_none() { + let state = GapFillState { + account_id, + active: Some(run), + history: Vec::new(), + }; + return upsert_impl(DB_MANAGER.db(), state); + } + Self::update_state(account_id, move |current| { + let mut updated = current.clone(); + if let Some(mut old) = updated.active.take() { + if old.status == GapFillStatus::Running { + old.status = GapFillStatus::Cancelled; + old.finished_at = Some(utc_now!()); + } + updated.history.push(old); + let keep = updated.history.len().saturating_sub(10); + if keep > 0 { + updated.history.drain(0..keep); + } + } + updated.active = Some(run); + Ok(updated) + }) + } + + /// Accumulates a per-folder outcome into the active run. + pub fn add_folder_result( + account_id: u64, + folder_name: String, + stats: GapFillFolderStats, + ) -> BichonResult<()> { + Self::update_state(account_id, move |current| { + let mut updated = current.clone(); + if let Some(ref mut run) = updated.active { + run.downloaded += stats.downloaded; + run.failed += stats.failed; + run.folders.insert(folder_name, stats); + } + Ok(updated) + }) + } + /// Updates the live per-folder progress of the active run (used during a + /// gap-fill scan so the UI can show per-folder progress without waiting + /// for the folder to finish). `candidate_count` is the planned total, + /// `downloaded` the current count, `message` an optional live hint + /// (e.g. slow-server notice). + pub fn update_folder_progress( + account_id: u64, + folder_name: String, + candidate_count: u64, + downloaded: u64, + message: Option, + ) -> BichonResult<()> { + Self::update_state(account_id, move |current| { + let mut updated = current.clone(); + if let Some(ref mut run) = updated.active { + let entry = run + .folders + .entry(folder_name.clone()) + .or_insert(GapFillFolderStats { + downloaded: 0, + failed: 0, + candidate_count, + message: None, + }); + entry.candidate_count = candidate_count; + entry.downloaded = downloaded; + entry.message = message; + } + Ok(updated) + }) + } + + /// Finalizes the active run: moves it to history with the given status and + /// totals. `failed`/`downloaded` are the authoritative accumulated values. + pub fn finish_run( + account_id: u64, + status: GapFillStatus, + downloaded: u64, + failed: u64, + ) -> BichonResult<()> { + Self::update_state(account_id, move |current| { + let mut updated = current.clone(); + if let Some(mut run) = updated.active.take() { + run.status = status; + run.finished_at = Some(utc_now!()); + run.downloaded = downloaded; + run.failed = failed; + updated.history.push(run); + let keep = updated.history.len().saturating_sub(10); + if keep > 0 { + updated.history.drain(0..keep); + } + } + Ok(updated) + }) + } + + /// Moves a stale Running active run into history as Cancelled. + /// + /// A Running `active` that survives a restart means the previous gap-fill + /// run was interrupted without finishing (process killed mid-scan). Leaving + /// it in place makes the UI show a phantom "Running" gap-fill. Callers + /// invoke this on startup, when no gap-fill is actually running. + /// + /// Returns `true` if a stale run was finalized. + pub fn finalize_stale_run(account_id: u64) -> BichonResult { + let stale = Self::get(account_id)? + .and_then(|s| s.active) + .map_or(false, |r| r.status == GapFillStatus::Running); + if stale { + Self::update_state(account_id, move |current| { + let mut updated = current.clone(); + if let Some(mut run) = updated.active.take() { + if run.status == GapFillStatus::Running { + run.status = GapFillStatus::Cancelled; + run.finished_at = Some(utc_now!()); + updated.history.push(run); + let keep = updated.history.len().saturating_sub(10); + if keep > 0 { + updated.history.drain(0..keep); + } + } else { + updated.active = Some(run); + } + } + Ok(updated) + })?; + } + Ok(stale) + } + + fn update_state( + account_id: u64, + updater: impl FnOnce(GapFillState) -> BichonResult + Send + 'static, + ) -> BichonResult<()> { + if Self::get(account_id)?.is_some() { + update_impl(DB_MANAGER.db(), &account_id.to_string(), updater)?; + } + Ok(()) + } +} diff --git a/crates/core/src/cache/imap/download/flow.rs b/crates/core/src/cache/imap/download/flow.rs index d41f53e..b80c83a 100644 --- a/crates/core/src/cache/imap/download/flow.rs +++ b/crates/core/src/cache/imap/download/flow.rs @@ -850,12 +850,25 @@ pub async fn reconcile_mailboxes( perform_incremental_sync(account, local_mailbox, remote_mailbox, token.clone()) .await? }; + info!( + account_id, + mailbox = %remote_mailbox.name, + local_highest = local_mailbox.highest_uid, + new_highest = new_highest_uid, + "reconcile: computed highest_uid for mailbox" + ); let mut updated = remote_mailbox.clone(); updated.highest_uid = new_highest_uid; // Update uid_validity with the resolved value (either from server or synthetic) if updated.uid_validity.is_none() { updated.uid_validity = Some(remote_uid_validity); } + info!( + account_id, + mailbox = %updated.name, + highest_uid = updated.highest_uid, + "reconcile: persisting mailbox state" + ); mailboxes_to_update.push(updated); } //The metadata of this mailbox must only be updated after a successful synchronization; @@ -971,8 +984,21 @@ async fn perform_incremental_sync( // Use stored highest_uid if available; otherwise fall back to Tantivy // query once (backward compatibility with pre-existing databases). let start_uid = match local_mailbox.highest_uid { - Some(uid) => uid as u64 + 1, + Some(uid) => { + info!( + account_id = account.id, + mailbox = %local_mailbox.name, + highest_uid = uid, + "incremental: stored highest_uid" + ); + uid as u64 + 1 + } None => { + warn!( + account_id = account.id, + mailbox = %local_mailbox.name, + "incremental: no stored highest_uid, falling back to Tantivy get_max_uid" + ); let local_max_uid = ENVELOPE_MANAGER.get_max_uid(account.id, local_mailbox.id)?; match local_max_uid { Some(uid) => uid + 1, diff --git a/crates/core/src/cache/imap/download/gap_fill.rs b/crates/core/src/cache/imap/download/gap_fill.rs new file mode 100644 index 0000000..1c66604 --- /dev/null +++ b/crates/core/src/cache/imap/download/gap_fill.rs @@ -0,0 +1,472 @@ +// +// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com) +// +// This file is part of the Bichon Email Archiving Project +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +use std::collections::{HashMap, HashSet}; + +use tracing::{info, warn}; + +use crate::error::code::ErrorCode; +use crate::raise_error; +use crate::{ + account::{ + migration::AccountModel, + state::{DownloadState, GapFillFolderStats, GapFillState}, + }, + cache::imap::mailbox::MailBox, + error::BichonResult, + imap::executor::{compress_uid_list, ImapExecutor, DEFAULT_BATCH_SIZE}, + store::tantivy::envelope::EnvelopeSnapshot, +}; + +/// Number of times a batch download is retried with a fresh connection before +/// it is counted as failed (mirrors the incremental sync path). +const MAX_NETWORK_RETRIES: u32 = 3; + +/// Lightweight header metadata for one remote message, fetched via +/// `FETCH (UID RFC822.SIZE INTERNALDATE BODY.PEEK[HEADER.FIELDS (MESSAGE-ID)])`. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct RemoteHeader { + pub uid: u32, + pub message_id: Option, + pub size: u64, + /// Epoch millis (internal date). + pub internal_date: i64, +} + +/// Which remote uids are missing locally. A remote message is "present" if its +/// message-id exists locally. The (size, internal_date) fingerprint is a +/// fallback for every remote message — not only those without a message-id — +/// because some paths store a different message-id locally than the remote +/// header carries (e.g. the SMTP path generates a random one), and servers +/// like Zoho/163 reuse the same message-id across different messages. A +/// duplicated local message-id still counts as present: re-downloading would +/// be deduplicated away anyway, so it can never repair the duplication. +pub fn compute_missing_uids(remote: &[RemoteHeader], local: &[EnvelopeSnapshot]) -> Vec { + let mut local_by_msg_id: HashMap<&str, usize> = HashMap::new(); + let mut local_by_fingerprint: HashSet<(u64, i64)> = HashSet::new(); + for snap in local { + if !snap.message_id.is_empty() { + *local_by_msg_id.entry(snap.message_id.as_str()).or_insert(0) += 1; + } + local_by_fingerprint.insert((snap.size, snap.internal_date)); + } + + let mut missing = Vec::new(); + for header in remote { + let present = match &header.message_id { + Some(msg_id) => local_by_msg_id + .get(msg_id.as_str()) + .is_some_and(|&c| c > 0), + None => false, + }; + if !present { + // Fingerprint fallback for every remote message, not just those + // without a message-id: the remote message-id may not exist + // locally even though the message is already stored (random + // synthetic ids on the SMTP path). + let fp_present = local_by_fingerprint.contains(&(header.size, header.internal_date)); + if !fp_present { + missing.push(header.uid); + } + } + } + missing +} + +/// Runs the gap-fill phase for one mailbox: enumerates every remote UID, +/// diffs against local envelopes, downloads the missing ones and returns the +/// per-folder outcome. Handles per-batch network errors by counting them as +/// failed (retryable on a later gap-fill run) instead of aborting the phase. +pub async fn gap_fill_mailbox( + account: &AccountModel, + local_mailbox: &MailBox, + remote_mailbox: &MailBox, + token: tokio_util::sync::CancellationToken, +) -> BichonResult { + let account_id = account.id; + let mut stats = GapFillFolderStats::default(); + + let mut session = ImapExecutor::create_connection(account_id).await?; + session + .examine(&remote_mailbox.encoded_name()) + .await + .map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?; + + // Phase 1: enumerate every remote UID. A huge mailbox can make the server + // take a while to answer `UID SEARCH ALL`; keep the UI informed instead of + // appearing stuck (the socket read timeout is the final backstop). + let search_started = std::time::Instant::now(); + let results = loop { + match tokio::time::timeout(std::time::Duration::from_secs(5), session.uid_search("ALL")) + .await + { + Ok(res) => { + break res + .map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))? + } + Err(_) => { + let stall = search_started.elapsed().as_secs_f64(); + if token.is_cancelled() { + session.logout().await.ok(); + return Ok(stats); + } + let _ = GapFillState::update_folder_progress( + account_id, + remote_mailbox.name.clone(), + 0, + 0, + crate::imap::executor::slow_server_message(None, Some(stall)), + ); + tracing::warn!( + account_id, + mailbox = %remote_mailbox.name, + stall_secs = format!("{:.0}", stall), + "gap-fill: UID SEARCH ALL taking long, still waiting" + ); + } + } + }; + let mut remote_uids: Vec = results.into_iter().collect(); + remote_uids.sort(); + if remote_uids.is_empty() { + session.logout().await.ok(); + return Ok(stats); + } + + // Phase 2: fetch header metadata for all remote uids in batches. + // A failed batch counts its uids as failed (they cannot be diffed) but + // does not abort the phase. A cancellation, however, leaves the header + // list incomplete so the diff would be unreliable — return immediately. + let mut remote_headers: Vec = Vec::with_capacity(remote_uids.len()); + let batch_size = account.download_batch_size.unwrap_or(DEFAULT_BATCH_SIZE) as usize; + let mut cancelled = false; + // Progress reported so far across all header batches, so the UI can show + // enumeration progress (and slow-server hints) while a huge mailbox is + // being scanned. + let headers_fetched = std::sync::Mutex::new(0u64); + for chunk in remote_uids.chunks(batch_size) { + if token.is_cancelled() { + cancelled = true; + break; + } + let seq_set = compress_uid_list(chunk.to_vec()); + match ImapExecutor::fetch_uid_headers( + &mut session, + &seq_set, + token.clone(), + Some(&|count, stall_secs| { + *headers_fetched.lock().unwrap() = count; + GapFillState::update_folder_progress( + account_id, + remote_mailbox.name.clone(), + remote_uids.len() as u64, + count, + crate::imap::executor::slow_server_message(None, stall_secs), + ) + }), + ) + .await + { + Ok(headers) => { + *headers_fetched.lock().unwrap() += headers.len() as u64; + remote_headers.extend(headers); + } + Err(e) => { + // Count the whole chunk as failed; the user can re-run + // gap-fill to retry. Do not abort the phase. + stats.failed += chunk.len() as u64; + let err_msg = format!("Gap-fill header batch failed: {:#?}", e); + warn!(account_id, mailbox = remote_mailbox.name, "{}", err_msg); + let _ = DownloadState::append_session_error(account_id, err_msg); + } + } + } + if cancelled { + session.logout().await.ok(); + GapFillState::update_folder_progress(account_id, remote_mailbox.name.clone(), 0, 0, None)?; + return Ok(stats); + } + remote_headers.sort_by_key(|h| h.uid); + session.logout().await.ok(); + + // Phase 3: local snapshot + let local_snapshots = crate::store::tantivy::envelope::ENVELOPE_MANAGER + .get_envelope_snapshots_for_mailbox(account_id, local_mailbox.id)?; + + // Phase 4: diff + let missing_uids = compute_missing_uids(&remote_headers, &local_snapshots); + stats.candidate_count = missing_uids.len() as u64; + if missing_uids.is_empty() { + info!( + account_id, + mailbox = remote_mailbox.name, + "Gap-fill: no missing emails" + ); + GapFillState::update_folder_progress(account_id, remote_mailbox.name.clone(), 0, 0, None)?; + return Ok(stats); + } + + // Phase 5: download missing in batches + let planned = missing_uids.len() as u64; + GapFillState::update_folder_progress( + account_id, + remote_mailbox.name.clone(), + planned, + 0, + None, + )?; + + let mut session2 = ImapExecutor::create_connection(account_id).await?; + session2 + .examine(&remote_mailbox.encoded_name()) + .await + .map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?; + + let batches = + crate::imap::executor::generate_uid_sequence_hashset(missing_uids.clone(), batch_size); + let mut downloaded = 0u64; + let mut failed = 0u64; + let mut cancelled = false; + for (index, batch) in batches.into_iter().enumerate() { + if token.is_cancelled() { + cancelled = true; + break; + } + // A slow server can stall a batch past the socket read timeout, same + // as in the incremental path. Retry such batches on a fresh connection + // instead of counting them as failed outright. + let mut retries = 0u32; + // Tracks the last cumulative count the progress callback reported. When + // a batch fails mid-stream the executor reports the already-stored + // count one final time before returning the error, so this is the + // number of emails of this batch that actually made it to disk. + let last_reported = std::sync::Mutex::new(0u64); + let batch_result = loop { + match ImapExecutor::uid_batch_retrieve_emails( + &mut session2, + account_id, + remote_mailbox.id, + &batch.0, + account.max_email_size_bytes, + token.clone(), + Some(&|cumulative, avg_secs, stall_secs| { + *last_reported.lock().unwrap() = cumulative; + let _ = GapFillState::update_folder_progress( + account_id, + remote_mailbox.name.clone(), + planned, + downloaded + cumulative, + crate::imap::executor::slow_server_message(avg_secs, stall_secs), + ); + Ok(()) + }), + ) + .await + { + Ok((processed, _throttled)) => break Ok(processed), + Err(e) if retries < MAX_NETWORK_RETRIES && e.code() == ErrorCode::NetworkError => { + retries += 1; + warn!( + account_id, + mailbox = remote_mailbox.name, + index, + retries, + "Gap-fill: network error on batch, reconnecting ({}/{})", + retries, + MAX_NETWORK_RETRIES + ); + match ImapExecutor::create_connection(account_id).await { + Ok(new_session) => { + session2 = new_session; + if let Err(e2) = session2.examine(&remote_mailbox.encoded_name()).await + { + let err_msg = format!( + "Gap-fill: re-examine failed after reconnect: {:#?}", + e2 + ); + DownloadState::append_session_error(account_id, err_msg)?; + break Err(e); + } + // Longer backoff than the original 1s/2s/4s: a + // throttling server needs time to recover. + let backoff = [5u64, 15, 30][(retries - 1) as usize]; + warn!( + account_id, + mailbox = remote_mailbox.name, + "Gap-fill: backing off {}s before retrying batch", + backoff + ); + tokio::time::sleep(std::time::Duration::from_secs(backoff)).await; + continue; + } + Err(e2) => { + tracing::error!(account_id, "Gap-fill: reconnection failed: {:#?}", e2); + break Err(e); + } + } + } + Err(e) => break Err(e), + } + }; + match batch_result { + Ok(processed) => { + downloaded += processed; + GapFillState::update_folder_progress( + account_id, + remote_mailbox.name.clone(), + planned, + downloaded, + None, + )?; + } + Err(e) => { + // The batch may have partially succeeded: emails already stored + // before the failure are counted as downloaded, only the rest + // of the batch is failed. The user can re-run gap-fill to + // retry the remainder; dedup makes re-downloading the stored + // ones harmless. Do not abort the phase. + let processed = *last_reported.lock().unwrap(); + downloaded += processed; + let remaining = batch.1.saturating_sub(processed); + failed += remaining; + let err_msg = format!( + "Gap-fill batch {} failed after {} processed: {:#?}", + index, processed, e + ); + warn!(account_id, mailbox = remote_mailbox.name, "{}", err_msg); + let _ = DownloadState::append_session_error(account_id, err_msg); + } + } + } + session2.logout().await.ok(); + + stats.downloaded = downloaded; + // Accumulate rather than overwrite: phase 2 (header batch) failures already + // counted into stats.failed and must survive alongside phase 5 failures. + stats.failed += failed; + // Final progress write into the independent gap-fill state (the folder is + // done; the outcome lands in GapFillRun.folders via add_folder_result). + GapFillState::update_folder_progress( + account_id, + remote_mailbox.name.clone(), + planned, + downloaded, + None, + )?; + + // Advance the mailbox's highest_uid so subsequent incremental syncs + // start after the newly downloaded messages. Only do this on a complete, + // uncancelled run where every planned message was downloaded and nothing + // failed (phase-2 header batch failures leave uids outside `planned` that + // must still be picked up by a later gap-fill run). + if !cancelled && downloaded == planned && stats.failed == 0 { + if let Some(max_uid) = missing_uids.last().copied() { + let mut updated = remote_mailbox.clone(); + updated.highest_uid = Some(max_uid.max(local_mailbox.highest_uid.unwrap_or(0))); + crate::cache::imap::mailbox::MailBox::batch_upsert(&[updated])?; + } + } + + Ok(stats) +} + +#[cfg(test)] +mod test { + use super::*; + + fn rh(uid: u32, message_id: Option<&str>, size: u64, internal_date: i64) -> RemoteHeader { + RemoteHeader { + uid, + message_id: message_id.map(|s| s.to_string()), + size, + internal_date, + } + } + + fn snap(message_id: &str, uid: u64, size: u64, internal_date: i64) -> EnvelopeSnapshot { + EnvelopeSnapshot { + message_id: message_id.to_string(), + uid, + size, + internal_date, + //subject: String::new(), + } + } + + #[test] + fn compute_missing_uids_message_id_diff() { + let remote = vec![ + rh(1, Some("a"), 10, 1000), + rh(2, Some("b"), 20, 2000), + rh(3, Some("c"), 30, 3000), + ]; + let local = vec![snap("a", 1, 10, 1000), snap("c", 3, 30, 3000)]; + let missing = compute_missing_uids(&remote, &local); + assert_eq!(missing, vec![2]); + } + + #[test] + fn compute_missing_uids_fingerprint_fallback() { + let remote = vec![rh(1, None, 10, 1000), rh(2, None, 20, 2000)]; + let local = vec![snap("generated-x", 1, 10, 1000)]; + let missing = compute_missing_uids(&remote, &local); + assert_eq!(missing, vec![2]); + } + + #[test] + fn compute_missing_uids_remote_duplicates_all_present() { + let remote = vec![rh(1, Some("dup"), 10, 1000), rh(2, Some("dup"), 10, 1000)]; + let local = vec![snap("dup", 1, 10, 1000)]; + let missing = compute_missing_uids(&remote, &local); + assert!(missing.is_empty()); + } + + #[test] + fn compute_missing_uids_local_duplicate_not_re_downloaded() { + // A message-id appearing more than once locally is NOT a reason to + // re-download: servers (Zoho, 163) legitimately reuse message-ids + // across different messages, and re-downloading would be deduplicated + // away anyway, so it can never repair the duplication. + let remote = vec![rh(1, Some("dup"), 10, 1000), rh(2, Some("dup"), 10, 1000)]; + let local = vec![snap("dup", 1, 10, 1000), snap("dup", 2, 10, 1000)]; + let missing = compute_missing_uids(&remote, &local); + assert!(missing.is_empty()); + } + + #[test] + fn compute_missing_uids_msgid_mismatch_but_fingerprint_hit() { + // The remote message-id does not exist locally (e.g. a different + // message-id was stored by the SMTP path) but the fingerprint matches: + // the message is already stored and must NOT be re-downloaded. + let remote = vec![rh(1, Some("remote-id@x.com"), 10, 1000)]; + let local = vec![snap("generated-random-id", 1, 10, 1000)]; + let missing = compute_missing_uids(&remote, &local); + assert!(missing.is_empty()); + } + + #[test] + fn compute_missing_uids_msgid_mismatch_and_fingerprint_miss() { + let remote = vec![ + rh(1, Some("remote-id@x.com"), 10, 1000), + rh(2, Some("remote-id-2@x.com"), 20, 2000), + ]; + let local = vec![snap("generated-random-id", 1, 10, 1000)]; + let missing = compute_missing_uids(&remote, &local); + assert_eq!(missing, vec![2]); + } +} diff --git a/crates/core/src/cache/imap/download/mod.rs b/crates/core/src/cache/imap/download/mod.rs index c4653f9..6f9055c 100644 --- a/crates/core/src/cache/imap/download/mod.rs +++ b/crates/core/src/cache/imap/download/mod.rs @@ -19,7 +19,10 @@ use crate::{ account::{ migration::{AccountModel, AccountType}, - state::{DownloadState, DownloadStatus, TriggerType}, + state::{ + DownloadState, DownloadStatus, GapFillFolderStats, GapFillState, GapFillStatus, + TriggerType, + }, }, cache::imap::{download::flow::FetchDirection, mailbox::MailBox}, error::BichonResult, @@ -31,10 +34,11 @@ use flow::reconcile_mailboxes; use rebuild::{rebuild_cache, rebuild_cache_by_date}; use std::time::Instant; use tokio_util::sync::CancellationToken; -use tracing::{debug, warn}; +use tracing::{debug, info, warn}; pub mod download_folders; pub mod download_type; +pub mod gap_fill; pub mod flow; pub mod rebuild; @@ -42,6 +46,7 @@ pub async fn process_imap_download( account: &AccountModel, token: CancellationToken, trigger_type: TriggerType, + run_gap_fill: bool, ) -> BichonResult<()> { assert_eq!(account.account_type, AccountType::IMAP); let start_time = Instant::now(); @@ -122,7 +127,60 @@ pub async fn process_imap_download( } let local_mailboxes = MailBox::list_all(account_id)?; - match reconcile_mailboxes(account, &remote_mailboxes, &local_mailboxes, token).await { + let reconcile_result = + reconcile_mailboxes(account, &remote_mailboxes, &local_mailboxes, token.clone()).await; + + // Gap-fill phase: only on explicit user request (manual download with + // "run gap-fill" checked). Enumerate every UID in the download folders and + // download anything missing locally. Not run on scheduled syncs. Gap-fill + // runs are tracked in their own state (independent of the download session) + // because they are repeatable until `failed == 0`. + if run_gap_fill { + GapFillState::start_run(account_id)?; + // Run inside a helper so a failure anywhere still finalizes the run: + // an abandoned active run would otherwise show as Running forever. + let run_outcome = gap_fill_phase( + account, + &local_mailboxes, + &remote_mailboxes, + token, + account_id, + ) + .await; + let (cancelled, total_downloaded, total_failed) = match run_outcome { + Ok(v) => v, + Err(e) => { + warn!(account_id = account_id, "Gap-fill phase error: {:#?}", e); + (false, 0, 1) + } + }; + let status = if cancelled { + GapFillStatus::Cancelled + } else if total_failed > 0 { + GapFillStatus::Failed + } else { + GapFillStatus::Success + }; + GapFillState::finish_run(account_id, status, total_downloaded, total_failed)?; + let summary = if cancelled { + format!( + "Gap-fill cancelled: {} downloaded, {} failed", + total_downloaded, total_failed + ) + } else { + format!( + "Gap-fill finished: {} downloaded, {} failed", + total_downloaded, total_failed + ) + }; + DownloadState::update_session_message(account_id, summary.clone())?; + info!(account_id = account_id, "{}", summary); + } + + // Finalize session status AFTER all phases so stats/progress written + // during gap-fill are not dropped (update_session_status closes the active + // session, moving it into history). + match reconcile_result { Ok(_) => DownloadState::update_session_status(account_id, DownloadStatus::Success, None)?, Err(e) => { let err_msg = format!("Email Download interrupted: {:#?}", e); @@ -134,6 +192,7 @@ pub async fn process_imap_download( )?; } } + let elapsed_time = start_time.elapsed().as_secs(); debug!( "Account{{{}}} Incremental sync completed: {} seconds elapsed.", @@ -141,3 +200,55 @@ pub async fn process_imap_download( ); Ok(()) } + +/// Runs the gap-fill phase across all download-folder mailboxes. Errors inside +/// are converted into a failed-run outcome instead of propagating, so the +/// caller can always finalize the active run. +async fn gap_fill_phase( + account: &AccountModel, + local_mailboxes: &[MailBox], + remote_mailboxes: &[MailBox], + token: CancellationToken, + account_id: u64, +) -> BichonResult<(bool, u64, u64)> { + let mut total_downloaded = 0u64; + let mut total_failed = 0u64; + let mut cancelled = false; + for local_mailbox in local_mailboxes { + let Some(remote) = remote_mailboxes.iter().find(|r| r.name == local_mailbox.name) else { + continue; + }; + if token.is_cancelled() { + cancelled = true; + break; + } + DownloadState::set_current_folder(account_id, local_mailbox.name.clone())?; + match gap_fill::gap_fill_mailbox(account, local_mailbox, remote, token.clone()).await { + Ok(stats) => { + total_downloaded += stats.downloaded; + total_failed += stats.failed; + GapFillState::add_folder_result(account_id, local_mailbox.name.clone(), stats)?; + } + Err(e) => { + let err_msg = format!( + "Gap-fill failed for mailbox '{}': {:#?}", + local_mailbox.name, e + ); + warn!(account_id = account_id, "{}", err_msg); + DownloadState::append_session_error(account_id, err_msg)?; + total_failed += 1; // count the mailbox as a failed unit + GapFillState::add_folder_result( + account_id, + local_mailbox.name.clone(), + GapFillFolderStats { + downloaded: 0, + failed: 1, + candidate_count: 0, + message: None, + }, + )?; + } + } + } + Ok((cancelled, total_downloaded, total_failed)) +} diff --git a/crates/core/src/cache/imap/task.rs b/crates/core/src/cache/imap/task.rs index 66659ef..76ed349 100644 --- a/crates/core/src/cache/imap/task.rs +++ b/crates/core/src/cache/imap/task.rs @@ -30,7 +30,7 @@ use std::{sync::LazyLock, time::Duration}; use tokio::sync::Mutex; use tokio::task::JoinHandle; use tokio_util::sync::CancellationToken; -use tracing::{error, info, warn}; +use tracing::{debug, error, info, warn}; static _DESCRIPTION: &str = "This task periodically synchronizes mailbox data for a specified account, ensuring that all local data is up-to-date."; const TASK_INTERVAL: Duration = Duration::from_secs(10); @@ -90,7 +90,7 @@ impl AccountDownTask { let internal_token = task_token.clone(); Box::pin(async move { if SYNC_TASKS.is_manual_running(account_id).await { - info!( + debug!( "Account {}: Scheduled task skipped (Manual task is running).", account_id ); @@ -98,7 +98,7 @@ impl AccountDownTask { } if !SYNC_TASKS.try_set_busy(account_id).await { - warn!( + debug!( "Account {}: Scheduled task skipped (Previous sync still active).", account_id ); @@ -141,6 +141,7 @@ impl AccountDownTask { &account, internal_token, TriggerType::Scheduled, + false, ) .await { @@ -211,7 +212,7 @@ impl AccountDownTask { } } - pub async fn start_manual_task(&self, account_id: u64) -> BichonResult<()> { + pub async fn start_manual_task(&self, account_id: u64, run_gap_fill: bool) -> BichonResult<()> { { if self.is_manual_running(account_id).await { return Err(raise_error!( @@ -253,7 +254,9 @@ impl AccountDownTask { return; } - if let Err(e) = process_imap_download(&account, token_clone, TriggerType::Manual).await + if let Err(e) = + process_imap_download(&account, token_clone, TriggerType::Manual, run_gap_fill) + .await { error!("Manual download failed for {}: {:?}", account_id, e); let error_msg = format!("error in account download task: {:#?}", e); diff --git a/crates/core/src/context/executors.rs b/crates/core/src/context/executors.rs index b47f952..6cfb0e4 100644 --- a/crates/core/src/context/executors.rs +++ b/crates/core/src/context/executors.rs @@ -17,7 +17,7 @@ // along with this program. If not, see . use crate::account::migration::AccountType; -use crate::account::state::DownloadState; +use crate::account::state::{DownloadState, GapFillState}; use crate::context::Initialize; use crate::{ { @@ -88,6 +88,23 @@ impl BichonContext { } Ok(false) => {} } + // Same for a leftover gap-fill run: a Running active run surviving + // startup is a phantom — nothing is scanning at this point. + match GapFillState::finalize_stale_run(account.id) { + Ok(true) => { + info!( + "Account {}: stale gap-fill run finalized on startup.", + account.id + ); + } + Err(e) => { + warn!( + "Failed to finalize stale gap-fill run for account {}: {:#?}", + account.id, e + ); + } + Ok(false) => {} + } DOWNLOAD_CONTROLLER .trigger_schedule(account.id, account.email) .await diff --git a/crates/core/src/imap/executor.rs b/crates/core/src/imap/executor.rs index c434142..e475619 100644 --- a/crates/core/src/imap/executor.rs +++ b/crates/core/src/imap/executor.rs @@ -178,6 +178,13 @@ impl ImapExecutor { })?; if results.is_empty() { + info!( + account_id = account.id, + mailbox = %mailbox.name, + start_uid, + date, + "fetch_new_mail_with_before: UID SEARCH returned no results" + ); DownloadState::update_folder_progress( account.id, mailbox.name.clone(), @@ -191,6 +198,20 @@ impl ImapExecutor { let mut uid_vec: Vec = results.into_iter().collect(); uid_vec.sort(); + // Same non-compliant-server guard as fetch_new_mail_range: `{start}:*` + // may be clamped by the server and return uids below start_uid, which + // are already stored locally (or are drift — gap-fill's job). + uid_vec.retain(|&uid| (uid as u64) >= start_uid); + info!( + account_id = account.id, + mailbox = %mailbox.name, + start_uid, + date, + found = uid_vec.len(), + first = uid_vec.first().copied(), + last = uid_vec.last().copied(), + "fetch_new_mail_with_before: UID SEARCH result" + ); let max_uid = uid_vec.last().copied(); let planned = uid_vec.len() as u64; let batch_size = account.download_batch_size.unwrap_or(DEFAULT_BATCH_SIZE) as usize; @@ -305,6 +326,21 @@ impl ImapExecutor { })?; let mut uid_vec: Vec = results.into_iter().collect(); uid_vec.sort(); + // Some non-compliant servers (e.g. Zoho) interpret `{start}:*` as a + // sequence range and clamp it, returning the last message even when + // start_uid exceeds the highest UID. Such results are below start_uid + // and are already stored locally (or are drift — gap-fill's job), so + // filter them out to avoid re-downloading the same email every sync. + uid_vec.retain(|&uid| (uid as u64) >= start_uid); + info!( + account_id = account.id, + mailbox = %mailbox.name, + start_uid, + found = uid_vec.len(), + first = uid_vec.first().copied(), + last = uid_vec.last().copied(), + "fetch_new_mail_range: UID SEARCH result" + ); if uid_vec.is_empty() { DownloadState::update_folder_progress( @@ -545,31 +581,51 @@ impl ImapExecutor { // average (the average only moves when a message actually arrives). const STALL_REPORT_INTERVAL: std::time::Duration = std::time::Duration::from_secs(5); loop { - let item = - match tokio::time::timeout(STALL_REPORT_INTERVAL, body_stream.try_next()).await { - Ok(item) => item - .map_err(|e| raise_error!(format!("{:#?}", e), classify_imap_error(&e)))?, - Err(_) => { - if token.is_cancelled() { - tracing::info!("Account {}: UID fetch stream interrupted.", account_id); - return Err(raise_error!( - "Stream cancelled".into(), - ErrorCode::InternalError - )); - } - let stall_secs = last_recv.elapsed().as_secs_f64(); - consecutive_stalls += 1; - + // On error, report the number of emails already stored through the + // progress callback so a partial batch is not counted as fully + // failed by callers (gap_fill uses the last reported count). + let item = match tokio::time::timeout(STALL_REPORT_INTERVAL, body_stream.try_next()).await + { + Ok(Ok(item)) => Ok(item), + Ok(Err(e)) => Err((count, e)), + Err(_) => { + if token.is_cancelled() { if let Some(progress) = progress { - progress(count, None, Some(stall_secs))?; + let _ = progress(count, None, None); } - continue; + return Err(raise_error!( + "Stream cancelled".into(), + ErrorCode::InternalError + )); } - }; + let stall_secs = last_recv.elapsed().as_secs_f64(); + consecutive_stalls += 1; + + if let Some(progress) = progress { + progress(count, None, Some(stall_secs))?; + } + continue; + } + }; + let item = match item { + Ok(item) => item, + Err((processed, e)) => { + if let Some(progress) = progress { + let _ = progress(processed, None, None); + } + return Err(raise_error!( + format!("{:#?}", e), + classify_imap_error(&e) + )); + } + }; let Some(fetch) = item else { break }; if token.is_cancelled() { tracing::info!("Account {}: UID fetch stream interrupted.", account_id); + if let Some(progress) = progress { + let _ = progress(count, None, None); + } return Err(raise_error!( "Stream cancelled".into(), ErrorCode::InternalError @@ -591,7 +647,12 @@ impl ImapExecutor { } else { None }; - extract_envelope_and_store_it(fetch, account_id, mailbox_id).await?; + if let Err(e) = extract_envelope_and_store_it(fetch, account_id, mailbox_id).await { + if let Some(progress) = progress { + let _ = progress(count, None, None); + } + return Err(e); + } count += 1; if let Some(progress) = progress { progress(count, avg_secs, None)?; @@ -688,6 +749,98 @@ impl ImapExecutor { } Ok(result) } + + /// Fetch lightweight header metadata (UID, size, internal date, message-id) + /// for a UID sequence-set, without downloading bodies. Used by gap-fill to + /// build the remote side of the diff. The fetch deliberately asks only for + /// the Message-ID header (no SUBJECT): parsing out a SUBJECT forces the + /// server to decode the full header for every message, which some servers + /// (e.g. Zoho) answer very slowly or in bursts. Message-ID alone is the + /// primary match key; the fingerprint fallback for messages without one + /// uses (size, internal date). + /// + /// `progress` (if given) is invoked with the number of headers received so + /// far and the current stall duration in seconds (None while the server is + /// feeding) every few seconds while the server is slow or silent, so + /// callers can surface "server is slow / rate limiting" feedback instead + /// of appearing stuck (mirrors `uid_batch_retrieve_emails`). + pub async fn fetch_uid_headers( + session: &mut Session>, + uid_set: &str, + token: CancellationToken, + progress: Option< + &(dyn Fn(u64, Option) -> BichonResult<()> + Send + Sync), + >, + ) -> BichonResult> { + let mut stream = session + .uid_fetch(uid_set, "(UID RFC822.SIZE INTERNALDATE BODY.PEEK[HEADER.FIELDS (MESSAGE-ID)])") + .await + .map_err(|e| raise_error!(format!("{:#?}", e), classify_imap_error(&e)))?; + + let mut result = Vec::new(); + let mut last_recv = std::time::Instant::now(); + // While the server is silent, re-report the current count every few + // seconds so the UI shows the wait climbing instead of a stale state. + const STALL_REPORT_INTERVAL: std::time::Duration = std::time::Duration::from_secs(5); + loop { + let item = + match tokio::time::timeout(STALL_REPORT_INTERVAL, stream.try_next()).await { + Ok(Ok(item)) => Ok(item), + Ok(Err(e)) => Err(raise_error!( + format!("{:#?}", e), + classify_imap_error(&e) + )), + Err(_) => { + if token.is_cancelled() { + return Err(raise_error!( + "Stream cancelled".into(), + ErrorCode::InternalError + )); + } + let stall_secs = last_recv.elapsed().as_secs_f64(); + if let Some(progress) = progress { + progress(result.len() as u64, Some(stall_secs))?; + } + tracing::warn!( + stall_secs = format!("{:.0}", stall_secs), + fetched = result.len(), + "fetch_uid_headers: server silent, waiting" + ); + continue; + } + }; + let item = match item { + Ok(item) => item, + Err(e) => return Err(e), + }; + let Some(fetch) = item else { break }; + last_recv = std::time::Instant::now(); + if token.is_cancelled() { + return Err(raise_error!( + "Stream cancelled".into(), + ErrorCode::InternalError + )); + } + let uid = fetch.uid.unwrap_or(0); + let size = fetch.size.unwrap_or(0) as u64; + let internal_date = fetch + .internal_date() + .map(|d| d.timestamp_millis()) + .unwrap_or(0); + let header_bytes = match fetch.header() { + Some(h) => h, + None => &[], + }; + let message_id = parse_message_id_header(header_bytes); + result.push(crate::cache::imap::download::gap_fill::RemoteHeader { + uid, + message_id, + size, + internal_date, + }); + } + Ok(result) + } } pub const DEFAULT_BATCH_SIZE: u32 = 30; diff --git a/crates/core/src/store/tantivy/envelope.rs b/crates/core/src/store/tantivy/envelope.rs index 9854fbf..4f4cfe7 100644 --- a/crates/core/src/store/tantivy/envelope.rs +++ b/crates/core/src/store/tantivy/envelope.rs @@ -81,6 +81,24 @@ use tracing::{info, warn}; pub static ENVELOPE_MANAGER: LazyLock = LazyLock::new(IndexManager::new); +/// Lightweight snapshot of a single envelope, used as the local side of +/// the gap-fill diff without materializing full `Envelope` structs. +/// +/// Loads every document of the mailbox into memory at once — the caller +/// must not use this for mailboxes too large to hold in a full in-memory +/// pass. Documents without a message-id are excluded by +/// `get_envelope_snapshots_for_mailbox` since the diff relies on +/// message-id / fingerprint matching. +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct EnvelopeSnapshot { + pub message_id: String, + pub uid: u64, + pub size: u64, + /// Epoch millis (internal date). + pub internal_date: i64, + //pub subject: String, +} + pub struct IndexManager { index: Arc, index_writer: Arc>, @@ -262,7 +280,7 @@ impl IndexManager { Box::new(TermQuery::new(account_term, IndexRecordOption::Basic)) } - fn mailbox_query(&self, account_id: u64, mailbox_id: u64) -> Box { + pub(crate) fn mailbox_query(&self, account_id: u64, mailbox_id: u64) -> Box { let account_query = TermQuery::new( Term::from_field_u64(SchemaTools::email_fields().f_account_id, account_id), IndexRecordOption::Basic, @@ -311,6 +329,68 @@ impl IndexManager { Ok(result) } + /// Returns lightweight snapshots of every envelope stored for a mailbox: + /// message-id, uid, size, internal date (epoch millis), subject. + /// Used by gap-fill to compute the local side of the diff without + /// materializing full `Envelope` structs. + pub fn get_envelope_snapshots_for_mailbox( + &self, + account_id: u64, + mailbox_id: u64, + ) -> BichonResult> { + let query = self.mailbox_query(account_id, mailbox_id); + let fields = SchemaTools::email_fields(); + let searcher = self.create_searcher()?; + + let docs = searcher + .search(&query, &DocSetCollector) + .map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?; + + let mut snapshots = Vec::with_capacity(docs.len()); + for doc_address in docs { + let doc = searcher + .doc::(doc_address) + .map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?; + let message_id = doc + .get_first(fields.f_message_id) + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + // Skip documents without a message-id: they are useless for + // gap-fill (the diff matches on message-id / fingerprint) and + // would otherwise surface as spurious "missing" entries in the + // difference set. Other fields keep their fallback defaults. + if message_id.is_empty() { + continue; + } + let uid = doc + .get_first(fields.f_uid) + .and_then(|v| v.as_u64()) + .unwrap_or(0); + let size = doc + .get_first(fields.f_size) + .and_then(|v| v.as_u64()) + .unwrap_or(0); + let internal_date = doc + .get_first(fields.f_internal_date) + .and_then(|v| v.as_i64()) + .unwrap_or(0); + // let subject = doc + // .get_first(fields.f_subject) + // .and_then(|v| v.as_str()) + // .unwrap_or("") + // .to_string(); + snapshots.push(EnvelopeSnapshot { + message_id, + uid, + size, + internal_date, + //subject, + }); + } + Ok(snapshots) + } + /// Check whether a specific Message-ID exists in a mailbox. /// Uses a TermQuery — O(1) per call, no allocation proportional to /// mailbox size. Suitable for large mailboxes where @@ -485,7 +565,10 @@ impl IndexManager { } } if !participant_queries.is_empty() { - subqueries.push((Occur::Must, Box::new(BooleanQuery::new(participant_queries)))); + subqueries.push(( + Occur::Must, + Box::new(BooleanQuery::new(participant_queries)), + )); } } @@ -2238,9 +2321,7 @@ mod tests { ])) }; - let docs = searcher - .search(&query, &DocSetCollector) - .unwrap(); + let docs = searcher.search(&query, &DocSetCollector).unwrap(); let mut ids: Vec = Vec::new(); for addr in docs { @@ -2582,9 +2663,7 @@ mod tests { writer.commit().unwrap(); } - let reader = index - .reader() - .expect("reader"); + let reader = index.reader().expect("reader"); let searcher = reader.searcher(); let query = TermQuery::new( @@ -2611,19 +2690,13 @@ mod tests { .get_first(f.f_ingest_at) .and_then(|v| v.as_i64()) .unwrap_or(0); - let uid = doc - .get_first(f.f_uid) - .and_then(|v| v.as_u64()) - .unwrap_or(0); + let uid = doc.get_first(f.f_uid).and_then(|v| v.as_u64()).unwrap_or(0); results.push((ingest_at, uid)); } // Verify primary sort by ingest_at is correct for w in results.windows(2) { - assert!( - w[0].0 <= w[1].0, - "ingest_at must be non-decreasing" - ); + assert!(w[0].0 <= w[1].0, "ingest_at must be non-decreasing"); } // Verify deterministic: run again, same order @@ -2660,7 +2733,12 @@ mod tests { println!("IMAP UID mapping (position → ingest_at, uid):"); for (pos, (ingest_at, uid)) in results.iter().enumerate() { - println!(" UID {} → (ingest_at={}, original_uid={})", pos + 1, ingest_at, uid); + println!( + " UID {} → (ingest_at={}, original_uid={})", + pos + 1, + ingest_at, + uid + ); } } } diff --git a/crates/server/src/rest/api/account.rs b/crates/server/src/rest/api/account.rs index 414cc8e..abc3218 100644 --- a/crates/server/src/rest/api/account.rs +++ b/crates/server/src/rest/api/account.rs @@ -24,7 +24,7 @@ use bichon_core::account::migration::{AccountModel, AccountType}; use bichon_core::account::payload::{ filter_accessible_accounts, AccountCreateRequest, AccountUpdateRequest, MinimalAccount, }; -use bichon_core::account::state::DownloadState; +use bichon_core::account::state::{DownloadState, GapFillState}; use bichon_core::account::stats::AccountStats; use bichon_core::account::view::AccountResp; use bichon_core::cache::imap::task::SYNC_TASKS; @@ -37,10 +37,22 @@ use bichon_core::users::UserModel; use poem_openapi::param::{Path, Query}; use poem_openapi::payload::Json; use poem_openapi::OpenApi; +use serde::{Deserialize, Serialize}; use std::collections::{HashMap, HashSet}; pub struct AccountApi; +/// Request body for `POST /accounts/:account_id/start-download`. +/// `Default` keeps the handler working for older clients that send no body. +#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize, poem_openapi::Object)] +pub struct StartDownloadRequest { + /// When true, run the gap-fill phase (enumerate all UIDs in the download + /// folders and download anything missing locally) after the incremental + /// sync. Defaults to false; omitted by older clients. + #[serde(default)] + pub run_gap_fill: bool, +} + #[OpenApi(prefix_path = "/api/v1", tag = "ApiTags::Account")] impl AccountApi { /// Get account details by account ID @@ -196,6 +208,30 @@ impl AccountApi { Ok(Json(state)) } + /// Get the gap-fill history of an account (independent of download sessions) + #[oai( + path = "/accounts/:account_id/gap-fill-stats", + method = "get", + operation_id = "accounts_gap_fill_state" + )] + async fn accounts_gap_fill_state( + &self, + /// The account ID to check gap-fill state for + account_id: Path, + context: WrappedContext, + ) -> ApiResult> { + let account_id = account_id.0; + AccountModel::check_account_exists(account_id)?; + context.require_permission(Some(account_id), Permission::ACCOUNT_READ_DETAILS)?; + let state = GapFillState::get(account_id)?; + let state = state.unwrap_or(GapFillState { + account_id, + active: None, + history: Vec::new(), + }); + Ok(Json(state)) + } + /// Start a manual download task for an account #[oai( path = "/accounts/:account_id/start-download", @@ -206,6 +242,7 @@ impl AccountApi { &self, /// The account ID to start download for account_id: Path, + body: Json, context: WrappedContext, ) -> ApiResult<()> { let account_id = account_id.0; @@ -217,7 +254,7 @@ impl AccountApi { ))?; } context.require_permission(Some(account_id), Permission::ACCOUNT_MANAGE)?; - SYNC_TASKS.start_manual_task(account_id).await?; + SYNC_TASKS.start_manual_task(account_id, body.run_gap_fill).await?; Ok(()) } diff --git a/web/src/api/account/api.ts b/web/src/api/account/api.ts index ae1604c..b3ea31d 100644 --- a/web/src/api/account/api.ts +++ b/web/src/api/account/api.ts @@ -67,6 +67,35 @@ export interface AccountError { error: string; } +export interface GapFillFolderStats { + downloaded: number; + failed: number; + candidate_count: number; + message?: string | null; +} + +export enum GapFillStatus { + Running = "Running", + Success = "Success", + Failed = "Failed", + Cancelled = "Cancelled", +} + +export interface GapFillRun { + started_at: number; + finished_at: number | null; + status: GapFillStatus; + folders: Record; + downloaded: number; + failed: number; +} + +export interface GapFillState { + account_id: number; + active: GapFillRun | null; + history: GapFillRun[]; +} + export interface DownloadSession { start_time: number; end_time: number | null; @@ -168,6 +197,11 @@ export const download_state = async (account_id: number) => { return response.data; }; +export const gap_fill_state = async (account_id: number) => { + const response = await axiosInstance.get(`api/v1/accounts/${account_id}/gap-fill-stats`); + return response.data; +}; + export const create_account = async (data: Record) => { const response = await axiosInstance.post("api/v1/account", data); return response.data; @@ -189,8 +223,8 @@ export const remove_account = async (account_id: number) => { }; -export const start_account_download = async (account_id: number) => { - const response = await axiosInstance.post(`api/v1/accounts/${account_id}/start-download`); +export const start_account_download = async (account_id: number, run_gap_fill = false) => { + const response = await axiosInstance.post(`api/v1/accounts/${account_id}/start-download`, { run_gap_fill }); return response.data; }; diff --git a/web/src/features/accounts/components/data-table-row-actions.tsx b/web/src/features/accounts/components/data-table-row-actions.tsx index 039097e..f96ca29 100644 --- a/web/src/features/accounts/components/data-table-row-actions.tsx +++ b/web/src/features/accounts/components/data-table-row-actions.tsx @@ -17,6 +17,7 @@ // along with this program. If not, see . +import { useState } from 'react' import { DotsHorizontalIcon } from '@radix-ui/react-icons' import { Row } from '@tanstack/react-table' import { IconEdit, IconPlayerPlay, IconPlayerStop, IconShieldLock, IconTrash } from '@tabler/icons-react' @@ -33,9 +34,10 @@ import { useAccountContext } from '../context' import { Mailbox, MessageSquareMore, Settings } from 'lucide-react' import { useTranslation } from 'react-i18next' import { useCurrentUser } from '@/hooks/use-current-user' -import { AccountModel, cancel_account_download, start_account_download } from '@/api/account/api' +import { AccountModel, cancel_account_download } from '@/api/account/api' import { toast } from '@/hooks/use-toast' import { useNavigate } from '@tanstack/react-router' +import { StartDownloadDialog } from './start-download-dialog' interface DataTableRowActionsProps { row: Row @@ -45,6 +47,7 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) { const { t } = useTranslation() const { setOpen, setCurrentRow } = useAccountContext() const navigate = useNavigate() + const [startDialogOpen, setStartDialogOpen] = useState(false) const account_type = row.original.account_type; const { require_any_permission } = useCurrentUser() @@ -64,16 +67,7 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) { const showDownload = !isDeleting && account_type === 'IMAP' && hasPermission; const handleStartDownload = async () => { - try { - await start_account_download(row.original.id); - toast({ title: t('accounts.downloadStarted') }); - } catch (error: any) { - toast({ - variant: "destructive", - title: t('accounts.downloadFailed'), - description: error.response?.data?.message || error.message - }); - } + setStartDialogOpen(true) } @@ -198,6 +192,11 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) { } + ) } diff --git a/web/src/features/accounts/components/running-state-dialog.tsx b/web/src/features/accounts/components/running-state-dialog.tsx index c39763f..f83e4c3 100644 --- a/web/src/features/accounts/components/running-state-dialog.tsx +++ b/web/src/features/accounts/components/running-state-dialog.tsx @@ -27,7 +27,7 @@ import { import { Button } from '@/components/ui/button' import { useQuery } from '@tanstack/react-query' import { useEffect, useState } from 'react' -import { download_state, DownloadStatus, AccountModel, FolderProgress } from '@/api/account/api' +import { download_state, gap_fill_state, DownloadStatus, AccountModel, FolderProgress, GapFillRun, GapFillStatus } from '@/api/account/api' import { format } from 'date-fns' import { ScrollArea } from '@/components/ui/scroll-area' import { Badge } from '@/components/ui/badge' @@ -136,6 +136,62 @@ function FolderDetailItem({ f, t }: { f: FolderProgress, t: (key: string) => str ) } +function GapFillRunDetail({ run, t }: { run: GapFillRun, t: (key: string) => string }) { + const folderEntries = Object.entries(run.folders) + if (folderEntries.length === 0) { + return ( +
+ {t('accounts.runningState.empty.no_gap_fill_folders')} +
+ ) + } + const isActive = run.status === GapFillStatus.Running + return ( +
+ {folderEntries.map(([name, stats]) => { + const pct = stats.candidate_count > 0 + ? Math.min(100, Math.round((stats.downloaded / stats.candidate_count) * 100)) + : 0 + return ( +
+
+ {name} + + {isActive && stats.candidate_count > 0 ? ( + + {stats.downloaded} / {stats.candidate_count} + + ) : ( + <> + {stats.downloaded} {t('accounts.runningState.gap_fill_downloaded_suffix')} + {stats.failed > 0 && ( + <> + · + {stats.failed} {t('accounts.runningState.gap_fill_failed_suffix')} + + )} + + )} + +
+ {isActive && stats.candidate_count > 0 && ( +
+
+
+ )} + {stats.message && ( +
+ +

{stats.message}

+
+ )} +
+ ) + })} +
+ ) +} + export function RunningStateDialog({ currentRow, open, onOpenChange }: Props) { const { t } = useTranslation(); @@ -149,6 +205,16 @@ export function RunningStateDialog({ currentRow, open, onOpenChange }: Props) { enabled: open && !!currentRow.id, }) + const { data: gapFillData } = useQuery({ + queryKey: ['gap-fill-state', currentRow.id], + queryFn: () => gap_fill_state(currentRow.id), + refetchInterval: (query) => { + const a = query.state.data?.active + return a && a.status === GapFillStatus.Running ? 5000 : false + }, + enabled: open && !!currentRow.id, + }) + const session = state?.active_session const history = state?.history || [] const isRunning = !!session && session.status === DownloadStatus.Running @@ -199,6 +265,10 @@ export function RunningStateDialog({ currentRow, open, onOpenChange }: Props) { {t('accounts.runningState.tabs.history')} {history.length} + + {t('accounts.runningState.tabs.gap_fill')} + {gapFillData?.active && {t('accounts.runningState.syncing')}} +
@@ -439,6 +509,55 @@ export function RunningStateDialog({ currentRow, open, onOpenChange }: Props) { + + +
+ {gapFillData?.active && ( +
+
+

{t('accounts.runningState.gap_fill_active')}

+ +
+ +
+ )} + {(!gapFillData?.history || gapFillData.history.length === 0) ? ( +
+ {t('accounts.runningState.empty.no_gap_fill_history')} +
+ ) : ( + + {[...gapFillData.history].reverse().map((run, i) => ( + + +
+
+
+ {format(new Date(run.started_at), 'yyyy-MM-dd HH:mm:ss')} +
+ +
+ + {run.downloaded} {t('accounts.runningState.gap_fill_downloaded_suffix')} + {run.failed > 0 && ( + <> + · + {run.failed} {t('accounts.runningState.gap_fill_failed_suffix')} + + )} + +
+
+ + + +
+ ))} +
+ )} +
+
+
)} diff --git a/web/src/features/accounts/components/start-download-dialog.tsx b/web/src/features/accounts/components/start-download-dialog.tsx new file mode 100644 index 0000000..93fd9f7 --- /dev/null +++ b/web/src/features/accounts/components/start-download-dialog.tsx @@ -0,0 +1,88 @@ +// +// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com) +// +// This file is part of the Bichon Email Archiving Project +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + + +import { useEffect, useState } from 'react' +import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog' +import { Button } from '@/components/ui/button' +import { Checkbox } from '@/components/ui/checkbox' +import { Label } from '@/components/ui/label' +import { useTranslation } from 'react-i18next' +import { toast } from '@/hooks/use-toast' +import { start_account_download, AccountModel } from '@/api/account/api' + +interface Props { + row: AccountModel + open: boolean + onOpenChange: (open: boolean) => void +} + +export function StartDownloadDialog({ row, open, onOpenChange }: Props) { + const { t } = useTranslation() + const [runGapFill, setRunGapFill] = useState(false) + const [submitting, setSubmitting] = useState(false) + + useEffect(() => { + if (open) { + setRunGapFill(false) + setSubmitting(false) + } + }, [open]) + + const handleConfirm = async () => { + setSubmitting(true) + try { + await start_account_download(row.id, runGapFill) + toast({ title: t('accounts.downloadStarted') }) + onOpenChange(false) + } catch (error: any) { + toast({ + variant: 'destructive', + title: t('accounts.downloadFailed'), + description: error.response?.data?.message || error.message, + }) + } finally { + setSubmitting(false) + } + } + + return ( + + + + {t('accounts.startDownload')} + + {t('accounts.startDownloadConfirmDesc')} + + +
+ setRunGapFill(!!v)} /> + +
+ + + + +
+
+ ) +} diff --git a/web/src/locales/ar.json b/web/src/locales/ar.json index 36538eb..c136840 100644 --- a/web/src/locales/ar.json +++ b/web/src/locales/ar.json @@ -240,6 +240,7 @@ "imapPortPlaceholder": "مثال: 993", "imapProxy": "استخدم وكيل SOCKS5 لاتصالات IMAP.", "incDownload": "الفاصل", + "incSync": "فترة التزامن", "lastSync": "آخر مزامنة", "leaveEmptyToKeepExisting": "اترك فارغًا للاحتفاظ بكلمة المرور الحالية، أو أدخل كلمة مرور جديدة لتحديثها.", "leaveEmptyToKeepPassword": "اترك فارغًا للاحتفاظ بكلمة المرور الحالية", @@ -271,6 +272,7 @@ "refreshToken": "رمز التحديث", "refreshTokenCopiedToClipboard": "تم نسخ رمز التحديث إلى الحافظة", "relative": "نسبي", + "runGapFill": "فحص البريد الجديد وتنزيله، مع ملء الرسائل القديمة المفقودة محلياً تلقائياً", "runningState": { "account": { "id": "معرّف الحساب" @@ -283,10 +285,15 @@ "no_active_download": "لا يوجد تنزيل نشط", "no_errors_current": "لا توجد أخطاء في الجلسة الحالية", "no_errors_session": "لا توجد أخطاء في هذه الجلسة", + "no_gap_fill_folders": "لا توجد مجلدات لمزامنة الرسائل المفقودة", + "no_gap_fill_history": "لا يوجد سجل لاستكمال الرسائل", "no_global_errors": "لا توجد أخطاء عامة", "no_history": "لا يوجد سجل" }, "folders": "صناديق البريد", + "gap_fill_active": "عملية الاستكمال قيد التشغيل", + "gap_fill_downloaded_suffix": "تم تنزيلها", + "gap_fill_failed_suffix": "فشلت", "latest": "الأحدث", "loading": { "fetching_account_state": "جارٍ تحميل حالة الحساب..." @@ -305,6 +312,7 @@ "active_session": "الجلسة النشطة", "errors": "أخطاء", "folders": "صناديق البريد", + "gap_fill": "استكمال الرسائل الناقصة", "history": "السجل" } }, @@ -345,6 +353,7 @@ "sinceRelativeDesc": "تنزيل رسائل الفترة الأخيرة فقط (مثل آخر 3 أشهر). يتحرك تاريخ البدء تلقائياً مع مرور الوقت.", "sinceRelativeValue": "تنزيل رسائل البريد الإلكتروني من آخر", "startDownload": "بدء التنزيل", + "startDownloadConfirmDesc": "بدء تنزيل بيانات الحسابات المحددة؟", "state": "الحالة", "status": "الحالة", "step": "الخطوة {{index}}", diff --git a/web/src/locales/da.json b/web/src/locales/da.json index e7a91f8..4b5aa80 100644 --- a/web/src/locales/da.json +++ b/web/src/locales/da.json @@ -240,6 +240,7 @@ "imapPortPlaceholder": "f.eks. 993", "imapProxy": "Brug en SOCKS5-proxy til IMAP-forbindelser.", "incDownload": "Interval", + "incSync": "Synk-interval", "lastSync": "Sidste synk.", "leaveEmptyToKeepExisting": "Lad stå tomt for at beholde den eksisterende adgangskode, eller indtast en ny for at opdatere den.", "leaveEmptyToKeepPassword": "Lad stå tomt for at beholde nuværende adgangskode", @@ -271,6 +272,7 @@ "refreshToken": "Opdateringstoken", "refreshTokenCopiedToClipboard": "Opdateringstoken kopieret til udklipsholderen", "relative": "Relativ", + "runGapFill": "Tjek for nye e-mails og fyld automatisk op på ældre manglende e-mails", "runningState": { "account": { "id": "Konto-ID" @@ -283,10 +285,15 @@ "no_active_download": "Ingen aktiv download", "no_errors_current": "Ingen fejl i nuværende session", "no_errors_session": "Ingen fejl i denne session", + "no_gap_fill_folders": "Ingen mapper med manglende e-mails", + "no_gap_fill_history": "Ingen historik over backfill", "no_global_errors": "Ingen globale fejl", "no_history": "Ingen historik" }, "folders": "postkasser", + "gap_fill_active": "Kørende backfill-kørsel", + "gap_fill_downloaded_suffix": "downloadet", + "gap_fill_failed_suffix": "misllykkedes", "latest": "SENESTE", "loading": { "fetching_account_state": "Henter kontostatus..." @@ -305,6 +312,7 @@ "active_session": "Aktiv session", "errors": "Fejl", "folders": "Postkasser", + "gap_fill": "Udfyld manglende e-mails", "history": "Historik" } }, @@ -345,6 +353,7 @@ "sinceRelativeDesc": "Download kun e-mails fra den seneste periode (f.eks. de seneste 3 måneder). Startdatoen flyttes automatisk fremad.", "sinceRelativeValue": "Download e-mails fra de sidste", "startDownload": "Start download", + "startDownloadConfirmDesc": "Start download for valgte konti?", "state": "Tilstand", "status": "Status", "step": "Trin {{index}}", diff --git a/web/src/locales/de.json b/web/src/locales/de.json index 06fe18a..a85c51c 100644 --- a/web/src/locales/de.json +++ b/web/src/locales/de.json @@ -240,6 +240,7 @@ "imapPortPlaceholder": "z.B. 993", "imapProxy": "SOCKS5-Proxy für IMAP-Verbindungen verwenden.", "incDownload": "Intervall", + "incSync": "Synch.-Intervall", "lastSync": "Letzte Synchronisierung", "leaveEmptyToKeepExisting": "Leer lassen, um das bestehende Passwort beizubehalten, oder einen neuen Wert eingeben, um es zu aktualisieren.", "leaveEmptyToKeepPassword": "Leer lassen, um das aktuelle Passwort beizubehalten", @@ -271,6 +272,7 @@ "refreshToken": "Aktualisierungstoken", "refreshTokenCopiedToClipboard": "Aktualisierungstoken in die Zwischenablage kopiert", "relative": "Relativ", + "runGapFill": "Neue Mails prüfen und ältere, lokal fehlende Mails automatisch nachladen", "runningState": { "account": { "id": "Konto-ID" @@ -283,10 +285,15 @@ "no_active_download": "Kein aktiver Download", "no_errors_current": "Keine Fehler in der aktuellen Sitzung", "no_errors_session": "Keine Fehler in dieser Sitzung", + "no_gap_fill_folders": "Keine Ordner für den Abgleich fehlender Mails", + "no_gap_fill_history": "Kein Backfill-Verlauf vorhanden", "no_global_errors": "Keine globalen Fehler", "no_history": "Kein Verlauf vorhanden" }, "folders": "Postfächer", + "gap_fill_active": "Laufender Backfill-Prozess", + "gap_fill_downloaded_suffix": "heruntergeladen", + "gap_fill_failed_suffix": "fehlgeschlagen", "latest": "NEU", "loading": { "fetching_account_state": "Kontostatus wird geladen..." @@ -305,6 +312,7 @@ "active_session": "Aktive Sitzung", "errors": "Fehler", "folders": "Postfächer", + "gap_fill": "Lückenfüllung", "history": "Verlauf" } }, @@ -345,6 +353,7 @@ "sinceRelativeDesc": "Nur E-Mails aus dem jüngsten Zeitraum herunterladen (z. B. letzte 3 Monate). Das Startdatum verschiebt sich automatisch.", "sinceRelativeValue": "E-Mails der letzten Zeit herunterladen", "startDownload": "Download starten", + "startDownloadConfirmDesc": "E-Mail-Download für gewählte Konten starten?", "state": "Zustand", "status": "Status", "step": "Schritt {{index}}", diff --git a/web/src/locales/en.json b/web/src/locales/en.json index ffa6f04..d05e6f0 100644 --- a/web/src/locales/en.json +++ b/web/src/locales/en.json @@ -242,6 +242,7 @@ "imapPortPlaceholder": "e.g 993", "imapProxy": "Use a proxy (http/socks5) for IMAP connections.", "incDownload": "Interval", + "incSync": "Sync interval", "lastSync": "Last Sync", "leaveEmptyToKeepExisting": "Leave empty to keep the existing password, or enter a new password to update it.", "leaveEmptyToKeepPassword": "Leave empty to keep current password", @@ -273,6 +274,7 @@ "refreshToken": "Refresh Token", "refreshTokenCopiedToClipboard": "Refresh token copied to clipboard", "relative": "Relative", + "runGapFill": "Check new emails and automatically backfill older missing emails", "runningState": { "account": { "id": "Account ID" @@ -285,10 +287,15 @@ "no_active_download": "No download in progress", "no_errors_current": "No errors in current session", "no_errors_session": "No errors in this session", + "no_gap_fill_folders": "No folders syncing missing messages", + "no_gap_fill_history": "No backfill history", "no_global_errors": "No global errors", "no_history": "No historical records found" }, "folders": "mailboxes", + "gap_fill_active": "Running backfill task", + "gap_fill_downloaded_suffix": "downloaded", + "gap_fill_failed_suffix": "failed", "latest": "LATEST", "loading": { "fetching_account_state": "Fetching account state..." @@ -307,6 +314,7 @@ "active_session": "Active Session", "errors": "Errors", "folders": "Mailboxes", + "gap_fill": "Backfill missing emails", "history": "History" } }, @@ -355,6 +363,7 @@ "sinceRelativeDesc": "Only download emails from the recent period (e.g. last 3 months). The start date automatically moves forward over time.", "sinceRelativeValue": "Download emails from the last", "startDownload": "Start download", + "startDownloadConfirmDesc": "Start downloading mail data for selected accounts?", "state": "State", "status": "Status", "step": "Step {{index}}", diff --git a/web/src/locales/es.json b/web/src/locales/es.json index 8000c55..4dabf91 100644 --- a/web/src/locales/es.json +++ b/web/src/locales/es.json @@ -240,6 +240,7 @@ "imapPortPlaceholder": "ej. 993", "imapProxy": "Usar proxy SOCKS5 para conexiones IMAP.", "incDownload": "Intervalo", + "incSync": "Intervalo de sinc.", "lastSync": "Última sincronización", "leaveEmptyToKeepExisting": "Deja vacío para mantener la contraseña existente, o introduce un nuevo valor para actualizarla.", "leaveEmptyToKeepPassword": "Deja vacío para mantener la contraseña actual", @@ -271,6 +272,7 @@ "refreshToken": "Token de actualización", "refreshTokenCopiedToClipboard": "Token de actualización copiado al portapapeles", "relative": "Relativa", + "runGapFill": "Verificar correos nuevos y rellenar automáticamente los faltantes antiguos", "runningState": { "account": { "id": "ID de cuenta" @@ -283,10 +285,15 @@ "no_active_download": "No hay descargas en curso", "no_errors_current": "Sin errores en la sesión actual", "no_errors_session": "Sin errores en esta sesión", + "no_gap_fill_folders": "No hay carpetas sincronizando mensajes faltantes", + "no_gap_fill_history": "Sin historial de backfill", "no_global_errors": "Sin errores globales", "no_history": "Sin historial" }, "folders": "buzones", + "gap_fill_active": "Tarea de backfill en ejecución", + "gap_fill_downloaded_suffix": "descargados", + "gap_fill_failed_suffix": "fallidos", "latest": "RECIENTE", "loading": { "fetching_account_state": "Obteniendo estado de la cuenta..." @@ -305,6 +312,7 @@ "active_session": "Sesión activa", "errors": "Errores", "folders": "Buzones", + "gap_fill": "Completar mensajes faltantes", "history": "Historial" } }, @@ -345,6 +353,7 @@ "sinceRelativeDesc": "Solo descargar correos del período reciente (ej. últimos 3 meses). La fecha de inicio avanza automáticamente.", "sinceRelativeValue": "Descargar correos de los últimos", "startDownload": "Iniciar descarga", + "startDownloadConfirmDesc": "¿Iniciar descarga para las cuentas seleccionadas?", "state": "Estado", "status": "Estado", "step": "Paso {{index}}", diff --git a/web/src/locales/fi.json b/web/src/locales/fi.json index fb03ddb..aa92d40 100644 --- a/web/src/locales/fi.json +++ b/web/src/locales/fi.json @@ -240,6 +240,7 @@ "imapPortPlaceholder": "esim. 993", "imapProxy": "Käytä SOCKS5-välityspalvelinta IMAP-yhteyksiin.", "incDownload": "Väli", + "incSync": "Synkronointiväli", "lastSync": "Viimeisin synkronointi", "leaveEmptyToKeepExisting": "Jätä tyhjäksi säilyttääksesi olemassa olevan salasanan, tai syötä uusi päivittääksesi sen.", "leaveEmptyToKeepPassword": "Jätä tyhjäksi säilyttääksesi nykyisen salasanan", @@ -271,6 +272,7 @@ "refreshToken": "Virheettömyystunnus", "refreshTokenCopiedToClipboard": "Virheettömyystunnus kopioitu leikepöydälle", "relative": "Suhteellinen", + "runGapFill": "Tarkista uudet sähköpostit ja täydennä vanhat puuttuvat viestit automaattisesti", "runningState": { "account": { "id": "Tilin ID" @@ -283,10 +285,15 @@ "no_active_download": "Ei aktiivista latausta", "no_errors_current": "Ei virheitä nykyisessä istunnossa", "no_errors_session": "Ei virheitä tässä istunnossa", + "no_gap_fill_folders": "Ei kansioita puuttuvien viestien täydennykseen", + "no_gap_fill_history": "Ei täydennyshistoriaa", "no_global_errors": "Ei yleisiä virheitä", "no_history": "Ei historiaa" }, "folders": "postilaatikot", + "gap_fill_active": "Käynnissä oleva täydennys", + "gap_fill_downloaded_suffix": "ladattu", + "gap_fill_failed_suffix": "epäonnistui", "latest": "UUSIN", "loading": { "fetching_account_state": "Haetaan tilan tietoja..." @@ -305,6 +312,7 @@ "active_session": "Aktiivinen istunto", "errors": "Virheet", "folders": "Postilaatikot", + "gap_fill": "Puuttuvien täydennys", "history": "Historia" } }, @@ -345,6 +353,7 @@ "sinceRelativeDesc": "Lataa vain viimeaikaiset sähköpostit (esim. viimeiset 3 kuukautta). Aloituspäivämäärä siirtyy automaattisesti eteenpäin.", "sinceRelativeValue": "Lataa sähköpostit viimeisimmiltä", "startDownload": "Aloita lataus", + "startDownloadConfirmDesc": "Aloitetaanko valittujen tilien lataus?", "state": "Tila", "status": "Tila", "step": "Vaihe {{index}}", diff --git a/web/src/locales/fr.json b/web/src/locales/fr.json index c535590..efdcc21 100644 --- a/web/src/locales/fr.json +++ b/web/src/locales/fr.json @@ -240,6 +240,7 @@ "imapPortPlaceholder": "ex. 993", "imapProxy": "Utiliser un proxy SOCKS5 pour les connexions IMAP.", "incDownload": "Intervalle", + "incSync": "Intervalle de synchro", "lastSync": "Dernière Synchronisation", "leaveEmptyToKeepExisting": "Laissez vide pour conserver le mot de passe existant, ou entrez-en un nouveau pour le mettre à jour.", "leaveEmptyToKeepPassword": "Laisser vide pour conserver le mot de passe actuel", @@ -271,6 +272,7 @@ "refreshToken": "Jeton de Rafraîchissement", "refreshTokenCopiedToClipboard": "Jeton de rafraîchissement copié dans le presse-papiers", "relative": "Relative", + "runGapFill": "Vérifier les nouveaux e-mails et rattraper automatiquement les anciens messages manquants", "runningState": { "account": { "id": "ID du compte" @@ -283,10 +285,15 @@ "no_active_download": "Aucun téléchargement en cours", "no_errors_current": "Aucune erreur dans la session actuelle", "no_errors_session": "Aucune erreur dans cette session", + "no_gap_fill_folders": "Aucun dossier en cours de synchronisation des messages manquants", + "no_gap_fill_history": "Aucun historique de rattrapage", "no_global_errors": "Aucune erreur globale", "no_history": "Aucun historique disponible" }, "folders": "boîtes mail", + "gap_fill_active": "Tâche de rattrapage en cours", + "gap_fill_downloaded_suffix": "téléchargés", + "gap_fill_failed_suffix": "échec(s)", "latest": "RÉCENT", "loading": { "fetching_account_state": "Chargement de l'état du compte..." @@ -305,6 +312,7 @@ "active_session": "Session active", "errors": "Erreurs", "folders": "Boîtes mail", + "gap_fill": "Rattrapage des messages", "history": "Historique" } }, @@ -345,6 +353,7 @@ "sinceRelativeDesc": "Télécharger uniquement les e-mails récents (ex. 3 derniers mois). La date de début avance automatiquement.", "sinceRelativeValue": "Télécharger les e-mails des derniers", "startDownload": "Lancer le téléchargement", + "startDownloadConfirmDesc": "Démarrer le téléchargement pour les comptes sélectionnés ?", "state": "État", "status": "Statut", "step": "Étape {{index}}", diff --git a/web/src/locales/it.json b/web/src/locales/it.json index 83be968..ce45cc8 100644 --- a/web/src/locales/it.json +++ b/web/src/locales/it.json @@ -240,6 +240,7 @@ "imapPortPlaceholder": "es. 993", "imapProxy": "Usa un proxy SOCKS5 per le connessioni IMAP.", "incDownload": "Intervallo", + "incSync": "Intervallo sinc.", "lastSync": "Ultima Sincronizzazione", "leaveEmptyToKeepExisting": "Lascia vuoto per mantenere la password esistente, o inseriscine una nuova per aggiornarla.", "leaveEmptyToKeepPassword": "Lascia vuoto per mantenere la password attuale", @@ -271,6 +272,7 @@ "refreshToken": "Token di Refresh", "refreshTokenCopiedToClipboard": "Token di refresh copiato negli appunti", "relative": "Relativa", + "runGapFill": "Controlla le nuove email e recupera automaticamente i vecchi messaggi mancanti", "runningState": { "account": { "id": "ID account" @@ -283,10 +285,15 @@ "no_active_download": "Nessun download in corso", "no_errors_current": "Nessun errore nella sessione corrente", "no_errors_session": "Nessun errore in questa sessione", + "no_gap_fill_folders": "Nessuna cartella con messaggi mancanti da scaricare", + "no_gap_fill_history": "Nessuna cronologia di backfill", "no_global_errors": "Nessun errore globale", "no_history": "Nessuna cronologia disponibile" }, "folders": "caselle di posta", + "gap_fill_active": "Attività di backfill in esecuzione", + "gap_fill_downloaded_suffix": "scaricati", + "gap_fill_failed_suffix": "non riusciti", "latest": "RECENTE", "loading": { "fetching_account_state": "Caricamento stato account..." @@ -305,6 +312,7 @@ "active_session": "Sessione attiva", "errors": "Errori", "folders": "Caselle di posta", + "gap_fill": "Recupero messaggi mancanti", "history": "Cronologia" } }, @@ -345,6 +353,7 @@ "sinceRelativeDesc": "Scarica solo le email del periodo recente (es. ultimi 3 mesi). La data di inizio si aggiorna automaticamente.", "sinceRelativeValue": "Scarica email degli ultimi", "startDownload": "Avvia download", + "startDownloadConfirmDesc": "Avviare il download per gli account selezionati?", "state": "Stato", "status": "Stato", "step": "Passo {{index}}", diff --git a/web/src/locales/jp.json b/web/src/locales/jp.json index 803146d..70a2904 100644 --- a/web/src/locales/jp.json +++ b/web/src/locales/jp.json @@ -240,6 +240,7 @@ "imapPortPlaceholder": "例: 993", "imapProxy": "IMAP接続にSOCKS5プロキシを使用します。", "incDownload": "間隔", + "incSync": "同期間隔", "lastSync": "最終同期", "leaveEmptyToKeepExisting": "既存のパスワードを保持する場合は空欄にしてください。更新する場合は新しいパスワードを入力してください。", "leaveEmptyToKeepPassword": "現在のパスワードを保持する場合は空欄にしてください", @@ -271,6 +272,7 @@ "refreshToken": "リフレッシュトークン", "refreshTokenCopiedToClipboard": "リフレッシュトークンをクリップボードにコピーしました", "relative": "相対", + "runGapFill": "新着メールを確認してダウンロードし、過去の未取得メールも自動的に差分補填します", "runningState": { "account": { "id": "アカウントID" @@ -283,10 +285,15 @@ "no_active_download": "現在ダウンロード中のタスクはありません", "no_errors_current": "現在のタスクにエラーはありません", "no_errors_session": "このタスクにエラーはありません", + "no_gap_fill_folders": "未取得メールを同期中のフォルダーはありません", + "no_gap_fill_history": "差分補填の履歴はありません", "no_global_errors": "全体エラーはありません", "no_history": "履歴がありません" }, "folders": "メールボックス", + "gap_fill_active": "実行中の差分補填タスク", + "gap_fill_downloaded_suffix": "件ダウンロード済み", + "gap_fill_failed_suffix": "件失敗", "latest": "最新", "loading": { "fetching_account_state": "アカウント状態を取得中..." @@ -305,6 +312,7 @@ "active_session": "実行中タスク", "errors": "エラー", "folders": "メールボックス", + "gap_fill": "差分メール補填", "history": "履歴" } }, @@ -345,6 +353,7 @@ "sinceRelativeDesc": "直近の期間(例:過去3ヶ月)のメールのみをダウンロードします。開始日は時間経過に伴い自動的に更新されます。", "sinceRelativeValue": "直近の期間のメールをダウンロード", "startDownload": "ダウンロードを開始", + "startDownloadConfirmDesc": "選択したアカウントのメールデータをダウンロードしますか?", "state": "状態", "status": "ステータス", "step": "ステップ {{index}}", diff --git a/web/src/locales/ko.json b/web/src/locales/ko.json index 078e537..2baba94 100644 --- a/web/src/locales/ko.json +++ b/web/src/locales/ko.json @@ -240,6 +240,7 @@ "imapPortPlaceholder": "예: 993", "imapProxy": "IMAP 연결에 SOCKS5 프록시를 사용합니다.", "incDownload": "간격", + "incSync": "동기화 간격", "lastSync": "최종 동기화", "leaveEmptyToKeepExisting": "기존 비밀번호를 유지하려면 비워 두십시오. 업데이트할 경우에만 새 비밀번호를 입력하십시오.", "leaveEmptyToKeepPassword": "현재 비밀번호를 유지하려면 비워 두십시오", @@ -271,6 +272,7 @@ "refreshToken": "새로 고침 토큰", "refreshTokenCopiedToClipboard": "새로 고침 토큰이 클립보드에 복사되었습니다", "relative": "상대적", + "runGapFill": "새 메일을 확인하여 다운로드하고, 누락된 과거 메일도 자동으로 백필합니다", "runningState": { "account": { "id": "계정 ID" @@ -283,10 +285,15 @@ "no_active_download": "진행 중인 다운로드가 없습니다", "no_errors_current": "현재 작업에 오류가 없습니다", "no_errors_session": "이 작업에 오류가 없습니다", + "no_gap_fill_folders": "누락된 메일을 동기화 중인 메일함이 없습니다", + "no_gap_fill_history": "백필 기록 없음", "no_global_errors": "전체 오류가 없습니다", "no_history": "기록이 없습니다" }, "folders": "메일함", + "gap_fill_active": "실행 중인 백필 작업", + "gap_fill_downloaded_suffix": "개 다운로드됨", + "gap_fill_failed_suffix": "개 실패", "latest": "최신", "loading": { "fetching_account_state": "계정 상태를 불러오는 중..." @@ -305,6 +312,7 @@ "active_session": "현재 작업", "errors": "오류", "folders": "메일함", + "gap_fill": "누락 메일 백필", "history": "기록" } }, @@ -345,6 +353,7 @@ "sinceRelativeDesc": "최근 기간(예: 지난 3개월)의 이메일만 다운로드합니다. 시작 날짜는 시간이 지남에 따라 자동으로 이동합니다.", "sinceRelativeValue": "최근 기간의 이메일 다운로드", "startDownload": "다운로드 시작", + "startDownloadConfirmDesc": "선택한 계정의 메일 데이터를 다운로드할까요?", "state": "상태", "status": "상태", "step": "단계 {{index}}", diff --git a/web/src/locales/nl.json b/web/src/locales/nl.json index 3605b1d..eeb19ad 100644 --- a/web/src/locales/nl.json +++ b/web/src/locales/nl.json @@ -240,6 +240,7 @@ "imapPortPlaceholder": "bv. 993", "imapProxy": "Gebruik een SOCKS5-proxy voor IMAP-verbindingen.", "incDownload": "Interval", + "incSync": "Synch.-interval", "lastSync": "Laatste Sync", "leaveEmptyToKeepExisting": "Laat leeg om het bestaande wachtwoord te behouden, of voer een nieuw wachtwoord in om het bij te werken.", "leaveEmptyToKeepPassword": "Laat leeg om huidig wachtwoord te behouden", @@ -271,6 +272,7 @@ "refreshToken": "Vernieuwingstoken (Refresh Token)", "refreshTokenCopiedToClipboard": "Vernieuwingstoken naar klembord gekopieerd", "relative": "Relatief", + "runGapFill": "Controleer op nieuwe e-mails en vul oudere ontbrekende e-mails automatisch aan", "runningState": { "account": { "id": "Account-ID" @@ -283,10 +285,15 @@ "no_active_download": "Geen actieve download", "no_errors_current": "Geen fouten in huidige sessie", "no_errors_session": "Geen fouten in deze sessie", + "no_gap_fill_folders": "Geen mappen die ontbrekende berichten synchroniseren", + "no_gap_fill_history": "Geen backfill-geschiedenis", "no_global_errors": "Geen globale fouten", "no_history": "Geen geschiedenis beschikbaar" }, "folders": "mailboxen", + "gap_fill_active": "Lopende backfill-taak", + "gap_fill_downloaded_suffix": "Gedownload", + "gap_fill_failed_suffix": "mislukt", "latest": "RECENT", "loading": { "fetching_account_state": "Accountstatus ophalen..." @@ -305,6 +312,7 @@ "active_session": "Actieve sessie", "errors": "Fouten", "folders": "Mailboxen", + "gap_fill": "Ontbrekende berichten aanvullen", "history": "Geschiedenis" } }, @@ -345,6 +353,7 @@ "sinceRelativeDesc": "Download alleen e-mails uit de afgelopen periode (bijv. laatste 3 maanden). De startdatum verschuift automatisch mee.", "sinceRelativeValue": "Download e-mails van de laatste", "startDownload": "Download starten", + "startDownloadConfirmDesc": "Download starten voor geselecteerde accounts?", "state": "Status", "status": "Status", "step": "Stap {{index}}", diff --git a/web/src/locales/no.json b/web/src/locales/no.json index 64e7fa9..c69c5ee 100644 --- a/web/src/locales/no.json +++ b/web/src/locales/no.json @@ -240,6 +240,7 @@ "imapPortPlaceholder": "f.eks. 993", "imapProxy": "Bruk en SOCKS5-proxy for IMAP-tilkoblinger.", "incDownload": "Intervall", + "incSync": "Synk-intervall", "lastSync": "Siste synkronisering", "leaveEmptyToKeepExisting": "La stå tomt for å beholde det eksisterende passordet, eller skriv inn et nytt passord for å oppdatere det.", "leaveEmptyToKeepPassword": "La stå tomt for å beholde nåværende passord", @@ -271,6 +272,7 @@ "refreshToken": "Oppfriskningstoken", "refreshTokenCopiedToClipboard": "Oppfriskningstoken kopiert til utklippstavlen", "relative": "Relativ", + "runGapFill": "Sjekk etter nye e-poster og fyll automatisk inn eldre manglende e-poster", "runningState": { "account": { "id": "Konto-ID" @@ -283,10 +285,15 @@ "no_active_download": "Ingen aktiv nedlasting", "no_errors_current": "Ingen feil i gjeldende økt", "no_errors_session": "Ingen feil i denne økten", + "no_gap_fill_folders": "Ingen mapper med manglende e-poster", + "no_gap_fill_history": "Ingen backfill-historikk", "no_global_errors": "Ingen globale feil", "no_history": "Ingen historikk" }, "folders": "postbokser", + "gap_fill_active": "Kjørende backfill-oppgave", + "gap_fill_downloaded_suffix": "lastet ned", + "gap_fill_failed_suffix": "mislyktes", "latest": "NYEST", "loading": { "fetching_account_state": "Henter kontostatus..." @@ -305,6 +312,7 @@ "active_session": "Aktiv økt", "errors": "Feil", "folders": "Postbokser", + "gap_fill": "Fyll inn manglende e-poster", "history": "Historikk" } }, @@ -345,6 +353,7 @@ "sinceRelativeDesc": "Last bare ned e-poster fra den siste perioden (f.eks. siste 3 måneder). Startdatoen flyttes automatisk fremover.", "sinceRelativeValue": "Last ned e-poster fra de siste", "startDownload": "Start nedlasting", + "startDownloadConfirmDesc": "Start nedlasting for valgte kontoer?", "state": "Tilstand", "status": "Status", "step": "Trinn {{index}}", diff --git a/web/src/locales/pl.json b/web/src/locales/pl.json index 3141ca2..ecd8e01 100644 --- a/web/src/locales/pl.json +++ b/web/src/locales/pl.json @@ -240,6 +240,7 @@ "imapPortPlaceholder": "np. 993 (szyfrowany TLS/SSL) lub 143 (bez szyfrowania)", "imapProxy": "Użyj proxy gniazda SOCKS5 dla połączeń IMAP.", "incDownload": "Interwał", + "incSync": "Interwał synch.", "lastSync": "OStatnia synchronizacja", "leaveEmptyToKeepExisting": "Pozostaw to pole puste, jeśli chcesz zachować dotychczasowe hasło lub wpisz nowe, aby zaktualizować.", "leaveEmptyToKeepPassword": "Pozostaw to pole puste, jeśli chcesz zachować dotychczasowe hasło", @@ -271,6 +272,7 @@ "refreshToken": "Odśwież token", "refreshTokenCopiedToClipboard": "Token odśwież skopiowany do schowka", "relative": "Wzglednie", + "runGapFill": "Sprawdzaj nowe e-maile i automatycznie uzupełniaj starsze, brakujące wiadomości", "runningState": { "account": { "id": "ID konta" @@ -283,10 +285,15 @@ "no_active_download": "Brak aktywnego pobierania", "no_errors_current": "Brak błędów w bieżącej sesji", "no_errors_session": "Brak błędów w tej sesji", + "no_gap_fill_folders": "Brak folderów z brakującymi wiadomościami do pobrania", + "no_gap_fill_history": "Brak historii uzupełniania", "no_global_errors": "Brak błędów globalnych", "no_history": "Brak historii" }, "folders": "skrzynki", + "gap_fill_active": "Trwające uzupełnianie", + "gap_fill_downloaded_suffix": "pobrano", + "gap_fill_failed_suffix": "niepowodzenie", "latest": "NAJNOWSZE", "loading": { "fetching_account_state": "Pobieranie stanu konta..." @@ -305,6 +312,7 @@ "active_session": "Aktywna sesja", "errors": "Błędy", "folders": "Skrzynki", + "gap_fill": "Uzupełnianie braków", "history": "Historia" } }, @@ -345,6 +353,7 @@ "sinceRelativeDesc": "Pobieraj tylko wiadomości z ostatniego okresu (np. ostatnie 3 miesiące). Data początkowa automatycznie przesuwa się w czasie.", "sinceRelativeValue": "Pobierz e-maile z ostatnich", "startDownload": "Uruchom pobieranie", + "startDownloadConfirmDesc": "Rozpocząć pobieranie dla wybranych kont?", "state": "Status", "status": "Status", "step": "Krok {{index}}", diff --git a/web/src/locales/pt.json b/web/src/locales/pt.json index 6e4ff43..ebc35c7 100644 --- a/web/src/locales/pt.json +++ b/web/src/locales/pt.json @@ -240,6 +240,7 @@ "imapPortPlaceholder": "Ex: 993", "imapProxy": "Usar proxy SOCKS5 para conexão IMAP.", "incDownload": "Intervalo", + "incSync": "Intervalo de sinc.", "lastSync": "Última Sincronização", "leaveEmptyToKeepExisting": "Deixe vazio para manter a senha existente. Insira a nova senha apenas se estiver atualizando.", "leaveEmptyToKeepPassword": "Deixe vazio para manter a senha atual", @@ -271,6 +272,7 @@ "refreshToken": "Token de Atualização", "refreshTokenCopiedToClipboard": "Token de atualização copiado para a área de transferência", "relative": "Relativo", + "runGapFill": "Verifique novos e-mails e preencha automaticamente e-mails antigos ausentes", "runningState": { "account": { "id": "ID da conta" @@ -283,10 +285,15 @@ "no_active_download": "Nenhum download em andamento", "no_errors_current": "Sem erros na sessão atual", "no_errors_session": "Sem erros nesta sessão", + "no_gap_fill_folders": "Nenhuma pasta sincronizando mensagens ausentes", + "no_gap_fill_history": "Nenhum histórico de backfill", "no_global_errors": "Sem erros globais", "no_history": "Sem histórico" }, "folders": "caixas de correio", + "gap_fill_active": "Tarefa de backfill em execução", + "gap_fill_downloaded_suffix": "baixados", + "gap_fill_failed_suffix": "falharam", "latest": "RECENTE", "loading": { "fetching_account_state": "Obtendo estado da conta..." @@ -305,6 +312,7 @@ "active_session": "Sessão ativa", "errors": "Erros", "folders": "Caixas de correio", + "gap_fill": "Preenchimento de lacunas", "history": "Histórico" } }, @@ -345,6 +353,7 @@ "sinceRelativeDesc": "Baixar apenas e-mails do período recente (ex: últimos 3 meses). A data de início avança automaticamente com o tempo.", "sinceRelativeValue": "Baixar e-mails dos últimos", "startDownload": "Iniciar download", + "startDownloadConfirmDesc": "Iniciar download para as contas selecionadas?", "state": "Estado", "status": "Status", "step": "Passo {{index}}", diff --git a/web/src/locales/ru.json b/web/src/locales/ru.json index de4c92c..552a833 100644 --- a/web/src/locales/ru.json +++ b/web/src/locales/ru.json @@ -240,6 +240,7 @@ "imapPortPlaceholder": "например, 993", "imapProxy": "Использовать SOCKS5 прокси для соединений IMAP.", "incDownload": "Интервал", + "incSync": "Интервал синхр.", "lastSync": "Посл. синхр.", "leaveEmptyToKeepExisting": "Оставьте пустым, чтобы сохранить существующий пароль, или введите новый для обновления.", "leaveEmptyToKeepPassword": "Оставьте пустым, чтобы сохранить текущий пароль", @@ -271,6 +272,7 @@ "refreshToken": "Refresh Token", "refreshTokenCopiedToClipboard": "Refresh token скопирован в буфер обмена", "relative": "Относительная", + "runGapFill": "Проверять новые письма и автоматически восполнять старые недостающие сообщения", "runningState": { "account": { "id": "ID аккаунта" @@ -283,10 +285,15 @@ "no_active_download": "Нет активных загрузок", "no_errors_current": "Нет ошибок в текущей сессии", "no_errors_session": "Нет ошибок в этой сессии", + "no_gap_fill_folders": "Нет папок для загрузки недостающих писем", + "no_gap_fill_history": "История восполнения отсутствует", "no_global_errors": "Нет глобальных ошибок", "no_history": "Нет истории" }, "folders": "почтовые ящики", + "gap_fill_active": "Выполняемый запуск восполнения", + "gap_fill_downloaded_suffix": "скачано", + "gap_fill_failed_suffix": "ошибок", "latest": "ПОСЛЕДНЕЕ", "loading": { "fetching_account_state": "Загрузка состояния аккаунта..." @@ -305,6 +312,7 @@ "active_session": "Активная сессия", "errors": "Ошибки", "folders": "Почтовые ящики", + "gap_fill": "Восполнение пропусков", "history": "История" } }, @@ -345,6 +353,7 @@ "sinceRelativeDesc": "Скачивать письма только за последний период (напр., за 3 месяца). Дата начала автоматически сдвигается со временем.", "sinceRelativeValue": "Скачать письма за последние", "startDownload": "Запустить загрузку", + "startDownloadConfirmDesc": "Начать загрузку почты для выбранных аккаунтов?", "state": "Состояние", "status": "Статус", "step": "Шаг {{index}}", diff --git a/web/src/locales/sv.json b/web/src/locales/sv.json index 06b9182..cf1c869 100644 --- a/web/src/locales/sv.json +++ b/web/src/locales/sv.json @@ -240,6 +240,7 @@ "imapPortPlaceholder": "t.ex. 993", "imapProxy": "Använd en SOCKS5-proxy för IMAP-anslutningar.", "incDownload": "Intervall", + "incSync": "Synkintervall", "lastSync": "Senaste synk", "leaveEmptyToKeepExisting": "Lämna tomt för att behålla det befintliga lösenordet, eller ange ett nytt för att uppdatera det.", "leaveEmptyToKeepPassword": "Lämna tomt för att behålla nuvarande lösenord", @@ -271,6 +272,7 @@ "refreshToken": "Uppdateringstoken", "refreshTokenCopiedToClipboard": "Uppdateringstoken kopierad till urklipp", "relative": "Relativ", + "runGapFill": "Kontrollera nya e-postmeddelanden och komplettera automatiskt äldre saknade meddelanden", "runningState": { "account": { "id": "Kontots ID" @@ -283,10 +285,15 @@ "no_active_download": "Ingen aktiv nedladdning", "no_errors_current": "Inga fel i aktuell session", "no_errors_session": "Inga fel i denna session", + "no_gap_fill_folders": "Inga mappar för synkning av saknade meddelanden", + "no_gap_fill_history": "Ingen kompletteringshistorik", "no_global_errors": "Inga globala fel", "no_history": "Ingen historik" }, "folders": "postlådor", + "gap_fill_active": "Pågående kompletteringskörning", + "gap_fill_downloaded_suffix": "nedladdade", + "gap_fill_failed_suffix": "misslyckades", "latest": "SENASTE", "loading": { "fetching_account_state": "Hämtar kontostatus..." @@ -305,6 +312,7 @@ "active_session": "Aktiv session", "errors": "Fel", "folders": "Postlådor", + "gap_fill": "Komplettera saknade meddelanden", "history": "Historik" } }, @@ -345,6 +353,7 @@ "sinceRelativeDesc": "Ladda endast ner e-postmeddelanden från den senaste perioden (t.ex. senaste 3 månaderna). Startdatumet flyttas automatiskt framåt.", "sinceRelativeValue": "Ladda ner e-post från de senaste", "startDownload": "Starta hämtning", + "startDownloadConfirmDesc": "Starta nedladdning för valda konton?", "state": "Tillstånd", "status": "Status", "step": "Steg {{index}}", diff --git a/web/src/locales/zh-tw.json b/web/src/locales/zh-tw.json index bbc95a5..430fce5 100644 --- a/web/src/locales/zh-tw.json +++ b/web/src/locales/zh-tw.json @@ -240,6 +240,7 @@ "imapPortPlaceholder": "例如:993", "imapProxy": "使用 SOCKS5 代理進行 IMAP 連線。", "incDownload": "間隔", + "incSync": "同步間隔", "lastSync": "上次同步", "leaveEmptyToKeepExisting": "保留現有密碼請留空。若要更新,請輸入新密碼。", "leaveEmptyToKeepPassword": "保留現有密碼請留空", @@ -271,6 +272,7 @@ "refreshToken": "更新權杖 (Refresh Token)", "refreshTokenCopiedToClipboard": "更新權杖已複製到剪貼簿", "relative": "相對", + "runGapFill": "檢查並下載新郵件,同時自動補全本地缺失的歷史舊郵件", "runningState": { "account": { "id": "帳戶 ID" @@ -283,10 +285,15 @@ "no_active_download": "目前沒有下載任務", "no_errors_current": "目前任務沒有錯誤", "no_errors_session": "此任務沒有錯誤", + "no_gap_fill_folders": "暫無需要補充缺失郵件的郵件資料夾", + "no_gap_fill_history": "暫無查漏補缺歷史記錄", "no_global_errors": "沒有全域錯誤", "no_history": "沒有歷史記錄" }, "folders": "個郵件夾", + "gap_fill_active": "執行中的查漏補缺任務", + "gap_fill_downloaded_suffix": "已下載", + "gap_fill_failed_suffix": "失敗", "latest": "最新", "loading": { "fetching_account_state": "正在取得帳戶狀態..." @@ -305,6 +312,7 @@ "active_session": "目前任務", "errors": "錯誤", "folders": "郵件夾", + "gap_fill": "缺失郵件查漏補缺", "history": "歷史記錄" } }, @@ -345,6 +353,7 @@ "sinceRelativeDesc": "僅下載最近一段時間(如過去 3 個月)的郵件。開始日期會隨時間推移自動向前滾動。", "sinceRelativeValue": "下載最近一段時期的郵件", "startDownload": "啟動下載", + "startDownloadConfirmDesc": "確定開始下載所選帳號的郵件資料?", "state": "狀態", "status": "狀態", "step": "步驟 {{index}}", diff --git a/web/src/locales/zh.json b/web/src/locales/zh.json index f21e3e5..3ca4ba4 100644 --- a/web/src/locales/zh.json +++ b/web/src/locales/zh.json @@ -242,6 +242,7 @@ "imapPortPlaceholder": "例如:993", "imapProxy": "为 IMAP 连接使用 SOCKS5 代理。", "incDownload": "间隔", + "incSync": "同步间隔", "lastSync": "最后同步", "leaveEmptyToKeepExisting": "留空以保持现有密码,或输入新密码进行更新。", "leaveEmptyToKeepPassword": "留空以保持当前密码", @@ -273,6 +274,7 @@ "refreshToken": "刷新令牌", "refreshTokenCopiedToClipboard": "刷新令牌已复制到剪贴板", "relative": "相对", + "runGapFill": "检查并下载新邮件,同时自动补全本地缺失的历史老邮件", "runningState": { "account": { "id": "账户 ID" @@ -285,10 +287,15 @@ "no_active_download": "当前没有下载任务", "no_errors_current": "当前任务无错误", "no_errors_session": "该任务无错误", + "no_gap_fill_folders": "暂无需要补充缺失邮件的邮件夹", + "no_gap_fill_history": "暂无查漏补缺历史记录", "no_global_errors": "暂无全局错误", "no_history": "暂无历史记录" }, "folders": "个文件夹", + "gap_fill_active": "运行中的查漏补缺任务", + "gap_fill_downloaded_suffix": "已下载", + "gap_fill_failed_suffix": "失败", "latest": "最新", "loading": { "fetching_account_state": "正在获取账户状态..." @@ -307,6 +314,7 @@ "active_session": "当前任务", "errors": "错误", "folders": "邮件夹", + "gap_fill": "缺失邮件查漏补缺", "history": "历史记录" } }, @@ -355,6 +363,7 @@ "sinceRelativeDesc": "仅下载最近一段时间(如过去 3 个月)的邮件。开始日期会随时间推移自动向后滚动。", "sinceRelativeValue": "下载最近一段时期的邮件", "startDownload": "启动下载", + "startDownloadConfirmDesc": "确定开始下载所选账号的邮件数据?", "state": "状态", "status": "状态", "step": "步骤 {{index}}",