mirror of
https://github.com/rustmailer/bichon.git
synced 2026-08-30 01:44:23 +00:00
feat(imap): gap-fill missing-mail repair with live progress UI
This commit is contained in:
@@ -67,6 +67,64 @@ pub struct FolderProgress {
|
||||
pub message: Option<String>,
|
||||
}
|
||||
|
||||
#[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<String>,
|
||||
}
|
||||
|
||||
#[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<i64>,
|
||||
pub status: GapFillStatus,
|
||||
/// Per-mailbox gap-fill outcome, keyed by mailbox name.
|
||||
pub folders: BTreeMap<String, GapFillFolderStats>,
|
||||
/// 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<GapFillRun>,
|
||||
/// Finished runs, most recent last.
|
||||
pub history: Vec<GapFillRun>,
|
||||
}
|
||||
|
||||
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<DownloadState> + Send + 'static,
|
||||
@@ -339,3 +409,161 @@ impl DownloadState {
|
||||
delete_impl::<DownloadState>(DB_MANAGER.db(), &account_id.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl GapFillState {
|
||||
pub fn get(account_id: u64) -> BichonResult<Option<GapFillState>> {
|
||||
find_impl::<GapFillState>(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<String>,
|
||||
) -> 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<bool> {
|
||||
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<GapFillState> + Send + 'static,
|
||||
) -> BichonResult<()> {
|
||||
if Self::get(account_id)?.is_some() {
|
||||
update_impl(DB_MANAGER.db(), &account_id.to_string(), updater)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
28
crates/core/src/cache/imap/download/flow.rs
vendored
28
crates/core/src/cache/imap/download/flow.rs
vendored
@@ -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,
|
||||
|
||||
472
crates/core/src/cache/imap/download/gap_fill.rs
vendored
Normal file
472
crates/core/src/cache/imap/download/gap_fill.rs
vendored
Normal file
@@ -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 <http://www.gnu.org/licenses/>.
|
||||
|
||||
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<String>,
|
||||
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<u32> {
|
||||
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<GapFillFolderStats> {
|
||||
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<u32> = 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<RemoteHeader> = 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]);
|
||||
}
|
||||
}
|
||||
117
crates/core/src/cache/imap/download/mod.rs
vendored
117
crates/core/src/cache/imap/download/mod.rs
vendored
@@ -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))
|
||||
}
|
||||
|
||||
13
crates/core/src/cache/imap/task.rs
vendored
13
crates/core/src/cache/imap/task.rs
vendored
@@ -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);
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
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
|
||||
|
||||
@@ -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<u32> = 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<u32> = 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<Box<dyn SessionStream>>,
|
||||
uid_set: &str,
|
||||
token: CancellationToken,
|
||||
progress: Option<
|
||||
&(dyn Fn(u64, Option<f64>) -> BichonResult<()> + Send + Sync),
|
||||
>,
|
||||
) -> BichonResult<Vec<crate::cache::imap::download::gap_fill::RemoteHeader>> {
|
||||
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;
|
||||
|
||||
@@ -81,6 +81,24 @@ use tracing::{info, warn};
|
||||
|
||||
pub static ENVELOPE_MANAGER: LazyLock<IndexManager> = 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>,
|
||||
index_writer: Arc<Mutex<IndexWriter>>,
|
||||
@@ -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<dyn Query> {
|
||||
pub(crate) fn mailbox_query(&self, account_id: u64, mailbox_id: u64) -> Box<dyn Query> {
|
||||
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<Vec<EnvelopeSnapshot>> {
|
||||
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::<TantivyDocument>(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<String> = 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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<u64>,
|
||||
context: WrappedContext,
|
||||
) -> ApiResult<Json<GapFillState>> {
|
||||
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<u64>,
|
||||
body: Json<StartDownloadRequest>,
|
||||
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(())
|
||||
}
|
||||
|
||||
|
||||
@@ -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<string, GapFillFolderStats>;
|
||||
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<GapFillState>(`api/v1/accounts/${account_id}/gap-fill-stats`);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const create_account = async (data: Record<string, any>) => {
|
||||
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;
|
||||
};
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
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<AccountModel>
|
||||
@@ -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) {
|
||||
</DropdownMenuItem>}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<StartDownloadDialog
|
||||
row={row.original}
|
||||
open={startDialogOpen}
|
||||
onOpenChange={setStartDialogOpen}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<div className="text-center py-8 text-muted-foreground italic text-xs">
|
||||
{t('accounts.runningState.empty.no_gap_fill_folders')}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
const isActive = run.status === GapFillStatus.Running
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{folderEntries.map(([name, stats]) => {
|
||||
const pct = stats.candidate_count > 0
|
||||
? Math.min(100, Math.round((stats.downloaded / stats.candidate_count) * 100))
|
||||
: 0
|
||||
return (
|
||||
<div key={name} className="py-1.5 border-b border-border/50 last:border-b-0">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-xs font-bold text-foreground truncate">{name}</span>
|
||||
<span className="text-[10px] font-bold text-muted-foreground whitespace-nowrap">
|
||||
{isActive && stats.candidate_count > 0 ? (
|
||||
<span className="text-blue-600">
|
||||
{stats.downloaded} <span className="opacity-50">/</span> {stats.candidate_count}
|
||||
</span>
|
||||
) : (
|
||||
<>
|
||||
<span className="text-blue-600">{stats.downloaded} {t('accounts.runningState.gap_fill_downloaded_suffix')}</span>
|
||||
{stats.failed > 0 && (
|
||||
<>
|
||||
<span className="mx-1 opacity-30">·</span>
|
||||
<span className="text-destructive">{stats.failed} {t('accounts.runningState.gap_fill_failed_suffix')}</span>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
{isActive && stats.candidate_count > 0 && (
|
||||
<div className="mt-1.5 h-1.5 w-full rounded-full bg-muted overflow-hidden">
|
||||
<div className="h-full rounded-full bg-blue-500 transition-all duration-500" style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
)}
|
||||
{stats.message && (
|
||||
<div className="mt-1.5 flex items-start gap-1.5">
|
||||
<AlertTriangle className="w-3 h-3 text-amber-600 mt-0.5 shrink-0" />
|
||||
<p className="text-[10px] font-medium text-amber-700 leading-relaxed">{stats.message}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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')}
|
||||
<Badge variant="secondary" className="ml-2 h-4 px-1 text-[10px] font-bold">{history.length}</Badge>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="gapfill" className="whitespace-nowrap data-[state=active]:border-b-2 data-[state=active]:border-primary rounded-none h-full bg-transparent shadow-none px-0 text-xs sm:text-sm font-bold">
|
||||
{t('accounts.runningState.tabs.gap_fill')}
|
||||
{gapFillData?.active && <Badge variant="secondary" className="ml-2 h-4 px-1 text-[10px] font-bold animate-pulse">{t('accounts.runningState.syncing')}</Badge>}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
|
||||
@@ -439,6 +509,55 @@ export function RunningStateDialog({ currentRow, open, onOpenChange }: Props) {
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</TabsContent>
|
||||
<TabsContent value="gapfill" className="h-full m-0 data-[state=active]:flex flex-col">
|
||||
<ScrollArea className="flex-1">
|
||||
<div className="p-4 sm:p-6 space-y-4">
|
||||
{gapFillData?.active && (
|
||||
<div className="rounded-xl border bg-card shadow-sm p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<p className="text-[10px] font-bold text-muted-foreground uppercase">{t('accounts.runningState.gap_fill_active')}</p>
|
||||
<StatusBadge status={gapFillData.active.status} />
|
||||
</div>
|
||||
<GapFillRunDetail run={gapFillData.active} t={t} />
|
||||
</div>
|
||||
)}
|
||||
{(!gapFillData?.history || gapFillData.history.length === 0) ? (
|
||||
<div className="text-center py-20 text-muted-foreground italic text-sm">
|
||||
{t('accounts.runningState.empty.no_gap_fill_history')}
|
||||
</div>
|
||||
) : (
|
||||
<Accordion type="single" collapsible className="space-y-3">
|
||||
{[...gapFillData.history].reverse().map((run, i) => (
|
||||
<AccordionItem key={i} value={`gapfill-${i}`} className="border rounded-xl bg-card shadow-sm px-4 border-border overflow-hidden">
|
||||
<AccordionTrigger className="hover:no-underline py-4">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between w-full pr-4 gap-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="text-xs sm:text-xs font-bold font-mono text-foreground">
|
||||
{format(new Date(run.started_at), 'yyyy-MM-dd HH:mm:ss')}
|
||||
</div>
|
||||
<StatusBadge status={run.status} />
|
||||
</div>
|
||||
<span className="text-[10px] font-bold text-muted-foreground bg-muted px-2 py-0.5 rounded-full self-start sm:self-auto">
|
||||
<span className="text-blue-600">{run.downloaded} {t('accounts.runningState.gap_fill_downloaded_suffix')}</span>
|
||||
{run.failed > 0 && (
|
||||
<>
|
||||
<span className="mx-1 opacity-30">·</span>
|
||||
<span className="text-destructive">{run.failed} {t('accounts.runningState.gap_fill_failed_suffix')}</span>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="pb-4 border-t pt-4 mt-1 border-border">
|
||||
<GapFillRunDetail run={run} t={t} />
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
))}
|
||||
</Accordion>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</TabsContent>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -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 <http://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
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 (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('accounts.startDownload')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t('accounts.startDownloadConfirmDesc')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex items-center gap-2 py-2">
|
||||
<Checkbox id="run-gap-fill" checked={runGapFill} onCheckedChange={(v) => setRunGapFill(!!v)} />
|
||||
<Label htmlFor="run-gap-fill">{t('accounts.runGapFill')}</Label>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={submitting}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button onClick={handleConfirm} disabled={submitting}>
|
||||
{t('accounts.startDownload')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -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}}",
|
||||
|
||||
@@ -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}}",
|
||||
|
||||
@@ -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}}",
|
||||
|
||||
@@ -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}}",
|
||||
|
||||
@@ -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}}",
|
||||
|
||||
@@ -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}}",
|
||||
|
||||
@@ -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}}",
|
||||
|
||||
@@ -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}}",
|
||||
|
||||
@@ -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}}",
|
||||
|
||||
@@ -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}}",
|
||||
|
||||
@@ -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}}",
|
||||
|
||||
@@ -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}}",
|
||||
|
||||
@@ -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}}",
|
||||
|
||||
@@ -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}}",
|
||||
|
||||
@@ -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}}",
|
||||
|
||||
@@ -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}}",
|
||||
|
||||
@@ -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}}",
|
||||
|
||||
@@ -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}}",
|
||||
|
||||
Reference in New Issue
Block a user