feat(imap): gap-fill missing-mail repair with live progress UI

This commit is contained in:
rustmailer
2026-08-06 01:18:20 +08:00
parent 75d345b742
commit ed02c642c8
31 changed files with 1589 additions and 62 deletions

View File

@@ -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(())
}
}

View File

@@ -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,

View 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]);
}
}

View File

@@ -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))
}

View File

@@ -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);

View File

@@ -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

View File

@@ -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;

View File

@@ -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
);
}
}
}

View File

@@ -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(())
}

View File

@@ -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;
};

View File

@@ -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}
/>
</>
)
}

View File

@@ -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>

View File

@@ -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>
)
}

View File

@@ -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}}",

View File

@@ -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}}",

View File

@@ -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}}",

View File

@@ -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}}",

View File

@@ -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}}",

View File

@@ -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}}",

View File

@@ -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}}",

View File

@@ -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}}",

View File

@@ -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}}",

View File

@@ -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}}",

View File

@@ -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}}",

View File

@@ -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}}",

View File

@@ -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}}",

View File

@@ -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}}",

View File

@@ -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}}",

View File

@@ -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}}",

View File

@@ -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}}",

View File

@@ -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}}",