mirror of
https://github.com/rustmailer/bichon.git
synced 2026-08-03 07:48:34 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3bfc080258 | ||
|
|
c3a725770c | ||
|
|
c736afffb0 | ||
|
|
62cb5264fd | ||
|
|
aebb94ee4e | ||
|
|
769630f9d7 | ||
|
|
368b18c45f | ||
|
|
42861f6cc9 | ||
|
|
327a3f39d9 | ||
|
|
427f7248d2 | ||
|
|
a2a51a2037 | ||
|
|
e8469da3bc |
Generated
+5
-5
@@ -293,7 +293,7 @@ checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
||||
|
||||
[[package]]
|
||||
name = "bichon-admin"
|
||||
version = "1.4.3"
|
||||
version = "1.5.1"
|
||||
dependencies = [
|
||||
"bichon-core",
|
||||
"console",
|
||||
@@ -311,7 +311,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "bichon-cli"
|
||||
version = "1.4.3"
|
||||
version = "1.5.1"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"bichon-core",
|
||||
@@ -337,7 +337,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "bichon-core"
|
||||
version = "1.4.3"
|
||||
version = "1.5.1"
|
||||
dependencies = [
|
||||
"async-imap",
|
||||
"base64 0.22.1",
|
||||
@@ -396,7 +396,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "bichon-server"
|
||||
version = "1.4.3"
|
||||
version = "1.5.1"
|
||||
dependencies = [
|
||||
"bichon-core",
|
||||
"bichon-smtp",
|
||||
@@ -420,7 +420,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "bichon-smtp"
|
||||
version = "1.4.3"
|
||||
version = "1.5.1"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"bichon-core",
|
||||
|
||||
+1
-1
@@ -12,7 +12,7 @@ members = [
|
||||
resolver = "2"
|
||||
|
||||
[workspace.package]
|
||||
version = "1.4.3"
|
||||
version = "1.5.1"
|
||||
edition = "2021"
|
||||
|
||||
[workspace.dependencies]
|
||||
|
||||
@@ -250,6 +250,7 @@ impl From<AccountV3> for AccountModel {
|
||||
account_type: value.account_type,
|
||||
download_interval_min: value.sync_interval_min,
|
||||
download_batch_size: value.sync_batch_size,
|
||||
max_email_size_bytes: None,
|
||||
known_folders: value.known_folders,
|
||||
created_at: value.created_at,
|
||||
updated_at: value.updated_at,
|
||||
@@ -261,6 +262,7 @@ impl From<AccountV3> for AccountModel {
|
||||
imap_quota_bytes: None,
|
||||
auto_download_new_mailboxes: None,
|
||||
download_schedule: None,
|
||||
deleting: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ use std::path::{Path, PathBuf};
|
||||
|
||||
use bichon_core::migrate::{
|
||||
count_eml_segments, do_migrate_segment, is_tantivy_index_dir,
|
||||
store::{LegacyDirs, NewDirs},
|
||||
store::{LegacyDirs, NewDirs, NewIndexWriter},
|
||||
};
|
||||
use console::style;
|
||||
use dialoguer::{theme::ColorfulTheme, Confirm, Input};
|
||||
@@ -326,6 +326,18 @@ pub fn handle_migration(theme: &ColorfulTheme) {
|
||||
.progress_chars("#>-"),
|
||||
);
|
||||
|
||||
let mut writer = match NewIndexWriter::open(NewDirs::new(
|
||||
new_index_path.clone(),
|
||||
new_data_path.clone(),
|
||||
)) {
|
||||
Ok(w) => w,
|
||||
Err(e) => {
|
||||
pb.finish_with_message(format!("{}", style("Migration failed.").red()));
|
||||
eprintln!("\n{} {:?}", style("✘").red().bold(), e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let mut grand_total_migrated: usize = 0;
|
||||
let mut grand_total_skipped: usize = 0;
|
||||
|
||||
@@ -337,7 +349,7 @@ pub fn handle_migration(theme: &ColorfulTheme) {
|
||||
match do_migrate_segment(
|
||||
batch_size,
|
||||
legacy,
|
||||
NewDirs::new(new_index_path.clone(), new_data_path.clone()),
|
||||
&mut writer,
|
||||
seg_idx,
|
||||
|msg| {
|
||||
if let Some(data) = msg.strip_prefix("TOTAL:") {
|
||||
@@ -407,6 +419,13 @@ pub fn handle_migration(theme: &ColorfulTheme) {
|
||||
pb.set_position((seg_idx + 1) as u64);
|
||||
}
|
||||
|
||||
pb.set_message(style("Finalizing indexes...").dim().to_string());
|
||||
if let Err(e) = writer.finish_writers() {
|
||||
pb.finish_with_message(format!("{}", style("Migration failed.").red()));
|
||||
eprintln!("\n{} {:?}", style("✘").red().bold(), e);
|
||||
return;
|
||||
}
|
||||
|
||||
pb.finish_with_message(format!(
|
||||
"Migration finished. Total: {}, Skipped: {}",
|
||||
grand_total_migrated, grand_total_skipped
|
||||
|
||||
@@ -84,6 +84,8 @@ pub struct Account {
|
||||
pub account_type: AccountType,
|
||||
pub download_interval_min: Option<i64>,
|
||||
pub download_batch_size: Option<u32>,
|
||||
#[serde(default)]
|
||||
pub max_email_size_bytes: Option<u64>,
|
||||
pub known_folders: Option<BTreeSet<String>>,
|
||||
pub created_at: i64,
|
||||
pub updated_at: i64,
|
||||
@@ -95,6 +97,8 @@ pub struct Account {
|
||||
pub imap_quota_window: Option<QuotaWindow>,
|
||||
pub auto_download_new_mailboxes: Option<bool>,
|
||||
pub download_schedule: Option<String>,
|
||||
#[serde(default)]
|
||||
pub deleting: bool,
|
||||
}
|
||||
|
||||
impl MemDbModel for Account {
|
||||
@@ -128,11 +132,13 @@ impl Account {
|
||||
pgp_key: request.pgp_key,
|
||||
created_by: user_id,
|
||||
download_batch_size: request.download_batch_size,
|
||||
max_email_size_bytes: request.max_email_size_bytes,
|
||||
date_before: request.date_before,
|
||||
auto_download_new_mailboxes: request.auto_download_new_mailboxes,
|
||||
imap_quota_bytes: request.imap_quota_bytes,
|
||||
imap_quota_window: request.imap_quota_window,
|
||||
download_schedule: request.download_schedule,
|
||||
deleting: false,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -220,14 +226,46 @@ impl Account {
|
||||
|
||||
pub async fn delete(account_id: u64) -> BichonResult<()> {
|
||||
let account = Self::get(account_id)?;
|
||||
if let Err(error) = Self::cleanup_account_resources_sequential(&account).await {
|
||||
tracing::error!(
|
||||
"[CLEANUP_ACCOUNT_ERROR] Account {}: failed to cleanup resources: {:#?}",
|
||||
account_id,
|
||||
error
|
||||
);
|
||||
return Err(error);
|
||||
|
||||
// Immediately stop scheduling to prevent new downloads
|
||||
if matches!(account.account_type, AccountType::IMAP) {
|
||||
SYNC_TASKS.stop(account.id).await?;
|
||||
}
|
||||
|
||||
// Mark as deleting and disabled so frontend shows status and download tasks skip it
|
||||
update_impl(
|
||||
DB_MANAGER.db(),
|
||||
&account_id.to_string(),
|
||||
move |current: Account| {
|
||||
let mut updated = current.clone();
|
||||
updated.deleting = true;
|
||||
updated.enabled = false;
|
||||
Ok(updated)
|
||||
},
|
||||
)?;
|
||||
|
||||
// Spawn background cleanup — heavy work (Tantivy, attachments) runs off the request path
|
||||
tokio::spawn(async move {
|
||||
if let Err(error) = Self::cleanup_account_resources_sequential(&account).await {
|
||||
tracing::error!(
|
||||
"[CLEANUP_ACCOUNT_ERROR] Account {}: cleanup failed, reverting deleting flag: {:#?}",
|
||||
account_id,
|
||||
error
|
||||
);
|
||||
// Revert deleting flag so the user can retry (only if account record still exists)
|
||||
let _ = update_impl(
|
||||
DB_MANAGER.db(),
|
||||
&account_id.to_string(),
|
||||
move |current: Account| {
|
||||
let mut updated = current.clone();
|
||||
updated.deleting = false;
|
||||
updated.enabled = true;
|
||||
Ok(updated)
|
||||
},
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -236,8 +274,8 @@ impl Account {
|
||||
}
|
||||
|
||||
async fn cleanup_account_resources_sequential(account: &AccountModel) -> BichonResult<()> {
|
||||
// Sync task already stopped in delete() before spawning this background task
|
||||
if matches!(account.account_type, AccountType::IMAP) {
|
||||
SYNC_TASKS.stop(account.id).await?;
|
||||
DownloadState::delete(account.id)?;
|
||||
}
|
||||
OAuth2AccessToken::try_delete(account.id)?;
|
||||
@@ -395,6 +433,10 @@ impl Account {
|
||||
new.download_batch_size = Some(*download_batch_size);
|
||||
}
|
||||
|
||||
if let Some(max_email_size_bytes) = request.max_email_size_bytes {
|
||||
new.max_email_size_bytes = Some(max_email_size_bytes);
|
||||
}
|
||||
|
||||
if let Some(use_proxy) = request.use_proxy {
|
||||
new.use_proxy = Some(use_proxy);
|
||||
}
|
||||
|
||||
@@ -48,6 +48,7 @@ pub struct AccountCreateRequest {
|
||||
oai(validator(minimum(value = "10"), maximum(value = "200")))
|
||||
)]
|
||||
pub download_batch_size: Option<u32>,
|
||||
pub max_email_size_bytes: Option<u64>,
|
||||
pub use_proxy: Option<u64>,
|
||||
pub use_dangerous: bool,
|
||||
pub pgp_key: Option<String>,
|
||||
@@ -165,6 +166,7 @@ pub struct AccountUpdateRequest {
|
||||
oai(validator(minimum(value = "10"), maximum(value = "200")))
|
||||
)]
|
||||
pub download_batch_size: Option<u32>,
|
||||
pub max_email_size_bytes: Option<u64>,
|
||||
/// Optional proxy ID for establishing the connection to external APIs (e.g., Gmail, Outlook).
|
||||
/// - If `None` or not provided, the client will connect directly to the API server.
|
||||
/// - If `Some(proxy_id)`, the client will use the pre-configured proxy with the given ID for API requests.
|
||||
|
||||
@@ -44,6 +44,7 @@ pub struct AccountResp {
|
||||
pub account_type: AccountType,
|
||||
pub download_interval_min: Option<i64>,
|
||||
pub download_batch_size: Option<u32>,
|
||||
pub max_email_size_bytes: Option<u64>,
|
||||
pub known_folders: Option<BTreeSet<String>>,
|
||||
pub created_at: i64,
|
||||
pub updated_at: i64,
|
||||
@@ -57,6 +58,7 @@ pub struct AccountResp {
|
||||
pub imap_quota_window: Option<QuotaWindow>,
|
||||
pub auto_download_new_mailboxes: Option<bool>,
|
||||
pub download_schedule: Option<String>,
|
||||
pub deleting: bool,
|
||||
}
|
||||
|
||||
impl AccountResp {
|
||||
@@ -76,6 +78,7 @@ impl AccountResp {
|
||||
account_type: account.account_type,
|
||||
download_interval_min: account.download_interval_min,
|
||||
download_batch_size: account.download_batch_size,
|
||||
max_email_size_bytes: account.max_email_size_bytes,
|
||||
known_folders: account.known_folders,
|
||||
created_at: account.created_at,
|
||||
updated_at: account.updated_at,
|
||||
@@ -93,6 +96,7 @@ impl AccountResp {
|
||||
imap_quota_window: account.imap_quota_window,
|
||||
auto_download_new_mailboxes: account.auto_download_new_mailboxes,
|
||||
download_schedule: account.download_schedule,
|
||||
deleting: account.deleting,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+150
-44
@@ -38,10 +38,12 @@ use crate::{
|
||||
store::tantivy::envelope::ENVELOPE_MANAGER,
|
||||
},
|
||||
};
|
||||
use std::time::Instant;
|
||||
use std::time::{Duration, Instant};
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use tracing::{debug, error, info, warn};
|
||||
|
||||
const MAX_NETWORK_RETRIES: u32 = 3;
|
||||
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub enum FetchDirection {
|
||||
@@ -152,17 +154,65 @@ pub async fn fetch_and_save_by_date(
|
||||
break;
|
||||
}
|
||||
// Fetch metadata for the current batch of UIDs
|
||||
match ImapExecutor::uid_batch_retrieve_emails(
|
||||
&mut session,
|
||||
account_id,
|
||||
mailbox.id,
|
||||
&batch.0,
|
||||
token.clone(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(_) => {
|
||||
current_processed += batch.1;
|
||||
let mut retries = 0u32;
|
||||
let batch_result = loop {
|
||||
match ImapExecutor::uid_batch_retrieve_emails(
|
||||
&mut session,
|
||||
account_id,
|
||||
mailbox.id,
|
||||
&batch.0,
|
||||
account.max_email_size_bytes,
|
||||
token.clone(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(processed) => break Ok(processed),
|
||||
Err(e)
|
||||
if retries < MAX_NETWORK_RETRIES && e.code() == ErrorCode::NetworkError =>
|
||||
{
|
||||
retries += 1;
|
||||
warn!(
|
||||
account_id,
|
||||
mailbox = mailbox.name,
|
||||
index,
|
||||
retries,
|
||||
"Network error on batch, reconnecting ({}/{})",
|
||||
retries,
|
||||
MAX_NETWORK_RETRIES
|
||||
);
|
||||
match ImapExecutor::create_connection(account_id).await {
|
||||
Ok(new_session) => {
|
||||
session = new_session;
|
||||
if let Err(e2) = session.examine(&mailbox.encoded_name()).await
|
||||
{
|
||||
let err_msg = format!(
|
||||
"Re-examine failed after reconnect: {:#?}",
|
||||
e2
|
||||
);
|
||||
DownloadState::append_session_error(
|
||||
account_id,
|
||||
err_msg,
|
||||
)?;
|
||||
break Err(e);
|
||||
}
|
||||
tokio::time::sleep(Duration::from_secs(
|
||||
1 << (retries - 1),
|
||||
))
|
||||
.await;
|
||||
continue;
|
||||
}
|
||||
Err(e2) => {
|
||||
error!(account_id, "Reconnection failed: {:#?}", e2);
|
||||
break Err(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => break Err(e),
|
||||
}
|
||||
};
|
||||
match batch_result {
|
||||
Ok(processed) => {
|
||||
current_processed += processed;
|
||||
DownloadState::update_folder_progress(
|
||||
account_id,
|
||||
mailbox.name.clone(),
|
||||
@@ -282,19 +332,60 @@ pub async fn fetch_and_save_full_mailbox(
|
||||
break;
|
||||
}
|
||||
|
||||
match ImapExecutor::batch_retrieve_emails(
|
||||
&mut session,
|
||||
account_id,
|
||||
mailbox_id,
|
||||
total,
|
||||
page as u64,
|
||||
page_size as u64,
|
||||
&mailbox.encoded_name(),
|
||||
token.clone(),
|
||||
&mut max_uid,
|
||||
)
|
||||
.await
|
||||
{
|
||||
let mut retries = 0u32;
|
||||
let batch_result = loop {
|
||||
match ImapExecutor::batch_retrieve_emails(
|
||||
&mut session,
|
||||
account_id,
|
||||
mailbox_id,
|
||||
total,
|
||||
page as u64,
|
||||
page_size as u64,
|
||||
&mailbox.encoded_name(),
|
||||
account.max_email_size_bytes,
|
||||
token.clone(),
|
||||
&mut max_uid,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(count) => break Ok(count),
|
||||
Err(e)
|
||||
if retries < MAX_NETWORK_RETRIES && e.code() == ErrorCode::NetworkError =>
|
||||
{
|
||||
retries += 1;
|
||||
warn!(
|
||||
account_id,
|
||||
mailbox = mailbox.name,
|
||||
page,
|
||||
retries,
|
||||
"Network error on batch, reconnecting ({}/{})",
|
||||
retries,
|
||||
MAX_NETWORK_RETRIES
|
||||
);
|
||||
match ImapExecutor::create_connection(account_id).await {
|
||||
Ok(new_session) => {
|
||||
session = new_session;
|
||||
if let Err(e2) = session.examine(&mailbox.encoded_name()).await {
|
||||
let err_msg = format!(
|
||||
"Re-examine failed after reconnect: {:#?}",
|
||||
e2
|
||||
);
|
||||
DownloadState::append_session_error(account_id, err_msg)?;
|
||||
break Err(e);
|
||||
}
|
||||
tokio::time::sleep(Duration::from_secs(1 << (retries - 1))).await;
|
||||
continue;
|
||||
}
|
||||
Err(e2) => {
|
||||
error!(account_id, "Reconnection failed: {:#?}", e2);
|
||||
break Err(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => break Err(e),
|
||||
}
|
||||
};
|
||||
match batch_result {
|
||||
Ok(count) => {
|
||||
current_processed += count as u64;
|
||||
DownloadState::update_folder_progress(
|
||||
@@ -337,6 +428,17 @@ pub async fn fetch_and_save_full_mailbox(
|
||||
Ok(max_uid)
|
||||
}
|
||||
|
||||
/// Generates a synthetic UIDVALIDITY for IMAP servers that don't provide it.
|
||||
/// Uses a stable hash of the mailbox name to ensure consistent IDs across sessions.
|
||||
fn generate_synthetic_uidvalidity(mailbox_name: &str) -> u32 {
|
||||
use std::collections::hash_map::DefaultHasher;
|
||||
use std::hash::{Hash, Hasher};
|
||||
|
||||
let mut hasher = DefaultHasher::new();
|
||||
mailbox_name.hash(&mut hasher);
|
||||
(hasher.finish() as u32).wrapping_add(1) // Avoid 0, which might be reserved
|
||||
}
|
||||
|
||||
pub async fn reconcile_mailboxes(
|
||||
account: &AccountModel,
|
||||
remote_mailboxes: &[MailBox],
|
||||
@@ -364,30 +466,30 @@ pub async fn reconcile_mailboxes(
|
||||
break;
|
||||
}
|
||||
|
||||
let new_highest_uid = if local_mailbox.uid_validity != remote_mailbox.uid_validity {
|
||||
if remote_mailbox.uid_validity.is_none() {
|
||||
let err_msg = format!(
|
||||
"Mailbox '{}' logic error: Server did not provide UIDVALIDITY.",
|
||||
local_mailbox.name
|
||||
// Handle missing UIDVALIDITY from non-compliant IMAP servers
|
||||
// (e.g., Tencent Enterprise Mail, etc.)
|
||||
let remote_uid_validity = match remote_mailbox.uid_validity {
|
||||
Some(uid) => uid,
|
||||
None => {
|
||||
// Generate a synthetic UIDVALIDITY based on mailbox name
|
||||
let synthetic_uid = generate_synthetic_uidvalidity(&remote_mailbox.name);
|
||||
|
||||
warn!(
|
||||
"Account {}: Mailbox '{}' - Server did not provide UIDVALIDITY. \
|
||||
Using synthetic UIDVALIDITY {} based on mailbox name. \
|
||||
This mailbox will be synced but may require periodic rebuilds if the server's mailbox structure changes.",
|
||||
account_id, remote_mailbox.name, synthetic_uid
|
||||
);
|
||||
|
||||
warn!("Account {}: {}", account_id, err_msg);
|
||||
|
||||
DownloadState::update_folder_progress(
|
||||
account_id,
|
||||
remote_mailbox.name.clone(),
|
||||
0,
|
||||
0,
|
||||
FolderStatus::Failed,
|
||||
Some(err_msg.clone()),
|
||||
)?;
|
||||
DownloadState::append_session_error(account_id, err_msg)?;
|
||||
continue;
|
||||
|
||||
synthetic_uid
|
||||
}
|
||||
};
|
||||
|
||||
let new_highest_uid = if local_mailbox.uid_validity != Some(remote_uid_validity) {
|
||||
info!(
|
||||
"Account {}: Mailbox '{}' detected with changed uid_validity (local: {:#?}, remote: {:#?}). \
|
||||
The mailbox data may be invalid, resetting its envelopes and rebuilding the cache.",
|
||||
account_id, local_mailbox.name, &local_mailbox.uid_validity, &remote_mailbox.uid_validity
|
||||
account_id, local_mailbox.name, &local_mailbox.uid_validity, &remote_uid_validity
|
||||
);
|
||||
|
||||
DownloadState::update_folder_progress(
|
||||
@@ -441,6 +543,10 @@ pub async fn reconcile_mailboxes(
|
||||
|
||||
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);
|
||||
}
|
||||
mailboxes_to_update.push(updated);
|
||||
}
|
||||
//The metadata of this mailbox must only be updated after a successful synchronization;
|
||||
|
||||
Vendored
+7
@@ -113,6 +113,9 @@ impl AccountDownTask {
|
||||
let account = AccountModel::get(account_id).ok();
|
||||
match account {
|
||||
Some(account) => {
|
||||
if account.deleting {
|
||||
return Ok(());
|
||||
}
|
||||
if !account.enabled {
|
||||
let last = LAST_WARN_TIME.load(Ordering::Relaxed);
|
||||
let now = utc_now!();
|
||||
@@ -246,6 +249,10 @@ impl AccountDownTask {
|
||||
}
|
||||
};
|
||||
|
||||
if account.deleting {
|
||||
return;
|
||||
}
|
||||
|
||||
if let Err(e) = process_imap_download(&account, token_clone, TriggerType::Manual).await
|
||||
{
|
||||
error!("Manual download failed for {}: {:?}", account_id, e);
|
||||
|
||||
@@ -16,4 +16,12 @@ pub enum BichonError {
|
||||
},
|
||||
}
|
||||
|
||||
impl BichonError {
|
||||
pub fn code(&self) -> ErrorCode {
|
||||
match self {
|
||||
BichonError::Generic { code, .. } => *code,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub type BichonResult<T, E = BichonError> = std::result::Result<T, E>;
|
||||
|
||||
@@ -32,6 +32,24 @@ use tokio_util::sync::CancellationToken;
|
||||
use tracing::info;
|
||||
|
||||
const BODY_FETCH_COMMAND: &str = "(UID INTERNALDATE RFC822.SIZE BODY.PEEK[])";
|
||||
const SIZE_ONLY_FETCH: &str = "(UID RFC822.SIZE)";
|
||||
|
||||
fn classify_imap_error(e: &async_imap::error::Error) -> ErrorCode {
|
||||
match e {
|
||||
async_imap::error::Error::Io(io) => matches!(
|
||||
io.kind(),
|
||||
std::io::ErrorKind::BrokenPipe
|
||||
| std::io::ErrorKind::ConnectionReset
|
||||
| std::io::ErrorKind::ConnectionAborted
|
||||
| std::io::ErrorKind::TimedOut
|
||||
| std::io::ErrorKind::UnexpectedEof
|
||||
)
|
||||
.then_some(ErrorCode::NetworkError)
|
||||
.unwrap_or(ErrorCode::ImapCommandFailed),
|
||||
async_imap::error::Error::ConnectionLost => ErrorCode::NetworkError,
|
||||
_ => ErrorCode::ImapCommandFailed,
|
||||
}
|
||||
}
|
||||
|
||||
pub struct ImapExecutor;
|
||||
|
||||
@@ -42,11 +60,11 @@ impl ImapExecutor {
|
||||
let list = session
|
||||
.list(Some(""), Some("*"))
|
||||
.await
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::ImapCommandFailed))?;
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), classify_imap_error(&e)))?;
|
||||
let result = list
|
||||
.try_collect::<Vec<Name>>()
|
||||
.await
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::ImapCommandFailed))?;
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), classify_imap_error(&e)))?;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
@@ -58,11 +76,11 @@ impl ImapExecutor {
|
||||
session
|
||||
.examine(mailbox_name)
|
||||
.await
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::ImapCommandFailed))?;
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), classify_imap_error(&e)))?;
|
||||
let result = session
|
||||
.uid_search(query)
|
||||
.await
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::ImapCommandFailed))?;
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), classify_imap_error(&e)))?;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
@@ -76,7 +94,7 @@ impl ImapExecutor {
|
||||
session
|
||||
.append(mailbox_name, flags, internaldate, content)
|
||||
.await
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::ImapCommandFailed))
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), classify_imap_error(&e)))
|
||||
}
|
||||
|
||||
/// Fetches new mail for a mailbox.
|
||||
@@ -101,7 +119,7 @@ impl ImapExecutor {
|
||||
session
|
||||
.examine(&mailbox.encoded_name())
|
||||
.await
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::ImapCommandFailed))?;
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), classify_imap_error(&e)))?;
|
||||
|
||||
match before {
|
||||
Some(date) => {
|
||||
@@ -131,7 +149,7 @@ impl ImapExecutor {
|
||||
let results = session.uid_search(&query).await.map_err(|e| {
|
||||
let err_msg = format!("UID SEARCH failed in [{}]: {:#?}", mailbox.name, e);
|
||||
let _ = DownloadState::append_session_error(account.id, err_msg);
|
||||
raise_error!(format!("{:#?}", e), ErrorCode::ImapCommandFailed)
|
||||
raise_error!(format!("{:#?}", e), classify_imap_error(&e))
|
||||
})?;
|
||||
|
||||
if results.is_empty() {
|
||||
@@ -183,15 +201,16 @@ impl ImapExecutor {
|
||||
ErrorCode::InternalError
|
||||
));
|
||||
}
|
||||
Self::uid_batch_retrieve_emails(
|
||||
let processed = Self::uid_batch_retrieve_emails(
|
||||
session,
|
||||
account.id,
|
||||
mailbox.id,
|
||||
&batch.0,
|
||||
account.max_email_size_bytes,
|
||||
token.clone(),
|
||||
)
|
||||
.await?;
|
||||
count += batch.1;
|
||||
count += processed;
|
||||
DownloadState::update_folder_progress(
|
||||
account.id,
|
||||
mailbox.name.clone(),
|
||||
@@ -235,15 +254,17 @@ impl ImapExecutor {
|
||||
.map_err(|e| {
|
||||
let err_msg = format!("UID FETCH failed in [{}]: {:#?}", mailbox.name, e);
|
||||
let _ = DownloadState::append_session_error(account.id, err_msg);
|
||||
raise_error!(format!("{:#?}", e), ErrorCode::ImapCommandFailed)
|
||||
raise_error!(format!("{:#?}", e), classify_imap_error(&e))
|
||||
})?;
|
||||
|
||||
let mut count = 0u64;
|
||||
let mut skipped = 0u64;
|
||||
let mut max_uid: Option<u32> = None;
|
||||
let size_limit = account.max_email_size_bytes.unwrap_or(DEFAULT_MAX_EMAIL_SIZE);
|
||||
while let Some(fetch) = stream
|
||||
.try_next()
|
||||
.await
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::ImapCommandFailed))?
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), classify_imap_error(&e)))?
|
||||
{
|
||||
if token.is_cancelled() {
|
||||
tracing::info!("Account {}: fetch_new_mail stream interrupted.", account.id);
|
||||
@@ -258,6 +279,20 @@ impl ImapExecutor {
|
||||
));
|
||||
}
|
||||
|
||||
let msg_size = fetch.size.unwrap_or(0) as u64;
|
||||
if msg_size > 0 && msg_size > size_limit {
|
||||
tracing::warn!(
|
||||
account_id = account.id,
|
||||
mailbox_id = mailbox.id,
|
||||
uid = fetch.uid,
|
||||
size = msg_size,
|
||||
limit = size_limit,
|
||||
"Skipping oversized email (streaming mode)"
|
||||
);
|
||||
skipped += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(uid) = fetch.uid {
|
||||
max_uid = Some(max_uid.unwrap_or(0).max(uid));
|
||||
}
|
||||
@@ -265,7 +300,8 @@ impl ImapExecutor {
|
||||
count += 1;
|
||||
}
|
||||
|
||||
if count == 0 {
|
||||
let total = count + skipped;
|
||||
if total == 0 {
|
||||
DownloadState::update_folder_progress(
|
||||
account.id,
|
||||
mailbox.name.clone(),
|
||||
@@ -278,10 +314,14 @@ impl ImapExecutor {
|
||||
DownloadState::update_folder_progress(
|
||||
account.id,
|
||||
mailbox.name.clone(),
|
||||
count,
|
||||
total,
|
||||
count,
|
||||
FolderStatus::Success,
|
||||
None,
|
||||
if skipped > 0 {
|
||||
Some(format!("{skipped} email(s) skipped due to size limit"))
|
||||
} else {
|
||||
None
|
||||
},
|
||||
)?;
|
||||
}
|
||||
|
||||
@@ -296,6 +336,7 @@ impl ImapExecutor {
|
||||
page: u64,
|
||||
page_size: u64,
|
||||
encoded_mailbox_name: &str,
|
||||
max_email_size_bytes: Option<u64>,
|
||||
token: CancellationToken,
|
||||
max_uid: &mut Option<u32>,
|
||||
) -> BichonResult<usize> {
|
||||
@@ -315,16 +356,55 @@ impl ImapExecutor {
|
||||
encoded_mailbox_name, sequence_set, page, page_size
|
||||
);
|
||||
|
||||
let mut stream = session
|
||||
.fetch(sequence_set.as_str(), BODY_FETCH_COMMAND)
|
||||
let limit = max_email_size_bytes.unwrap_or(DEFAULT_MAX_EMAIL_SIZE);
|
||||
|
||||
// PASS 1: fetch only SIZE to identify oversized messages
|
||||
let acceptable_uids = {
|
||||
let mut size_stream = session
|
||||
.fetch(sequence_set.as_str(), SIZE_ONLY_FETCH)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
raise_error!(format!("{:#?}", e), classify_imap_error(&e))
|
||||
})?;
|
||||
|
||||
let mut uids: Vec<u32> = Vec::new();
|
||||
while let Some(fetch) = size_stream.try_next().await.map_err(|e| {
|
||||
raise_error!(format!("{:#?}", e), classify_imap_error(&e))
|
||||
})? {
|
||||
let uid = fetch.uid.unwrap_or(0);
|
||||
let msg_size = fetch.size.unwrap_or(0) as u64;
|
||||
if msg_size == 0 || msg_size <= limit {
|
||||
uids.push(uid);
|
||||
} else {
|
||||
tracing::warn!(
|
||||
account_id,
|
||||
mailbox_id,
|
||||
uid,
|
||||
size = msg_size,
|
||||
limit,
|
||||
"Skipping oversized email"
|
||||
);
|
||||
}
|
||||
}
|
||||
uids
|
||||
};
|
||||
|
||||
if acceptable_uids.is_empty() {
|
||||
return Ok(0);
|
||||
}
|
||||
|
||||
// PASS 2: fetch bodies only for acceptable UIDs
|
||||
let filtered = compress_uid_list(acceptable_uids);
|
||||
let mut body_stream = session
|
||||
.uid_fetch(&filtered, BODY_FETCH_COMMAND)
|
||||
.await
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::ImapCommandFailed))?;
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), classify_imap_error(&e)))?;
|
||||
|
||||
let mut count = 0;
|
||||
while let Some(fetch) = stream
|
||||
while let Some(fetch) = body_stream
|
||||
.try_next()
|
||||
.await
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::ImapCommandFailed))?
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), classify_imap_error(&e)))?
|
||||
{
|
||||
if token.is_cancelled() {
|
||||
tracing::info!("Account {}: UID fetch stream interrupted.", account_id);
|
||||
@@ -347,16 +427,58 @@ impl ImapExecutor {
|
||||
account_id: u64,
|
||||
mailbox_id: u64,
|
||||
uid_set: &str,
|
||||
max_email_size_bytes: Option<u64>,
|
||||
token: CancellationToken,
|
||||
) -> BichonResult<()> {
|
||||
let mut stream = session
|
||||
.uid_fetch(uid_set, BODY_FETCH_COMMAND)
|
||||
) -> BichonResult<u64> {
|
||||
let limit = max_email_size_bytes.unwrap_or(DEFAULT_MAX_EMAIL_SIZE);
|
||||
|
||||
// PASS 1: fetch only SIZE to identify oversized messages
|
||||
let acceptable_uids = {
|
||||
let mut size_stream = session
|
||||
.uid_fetch(uid_set, SIZE_ONLY_FETCH)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
raise_error!(format!("{:#?}", e), classify_imap_error(&e))
|
||||
})?;
|
||||
|
||||
let mut uids: Vec<u32> = Vec::new();
|
||||
while let Some(fetch) = size_stream.try_next().await.map_err(|e| {
|
||||
raise_error!(format!("{:#?}", e), classify_imap_error(&e))
|
||||
})? {
|
||||
let uid = fetch.uid.unwrap_or(0);
|
||||
let msg_size = fetch.size.unwrap_or(0) as u64;
|
||||
if msg_size == 0 || msg_size <= limit {
|
||||
uids.push(uid);
|
||||
} else {
|
||||
tracing::warn!(
|
||||
account_id,
|
||||
mailbox_id,
|
||||
uid,
|
||||
size = msg_size,
|
||||
limit,
|
||||
"Skipping oversized email"
|
||||
);
|
||||
}
|
||||
}
|
||||
uids
|
||||
};
|
||||
|
||||
if acceptable_uids.is_empty() {
|
||||
return Ok(0);
|
||||
}
|
||||
|
||||
// PASS 2: fetch bodies only for acceptable UIDs
|
||||
let filtered = compress_uid_list(acceptable_uids);
|
||||
let mut body_stream = session
|
||||
.uid_fetch(&filtered, BODY_FETCH_COMMAND)
|
||||
.await
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::ImapCommandFailed))?;
|
||||
while let Some(fetch) = stream
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), classify_imap_error(&e)))?;
|
||||
|
||||
let mut count = 0u64;
|
||||
while let Some(fetch) = body_stream
|
||||
.try_next()
|
||||
.await
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::ImapCommandFailed))?
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), classify_imap_error(&e)))?
|
||||
{
|
||||
if token.is_cancelled() {
|
||||
tracing::info!("Account {}: UID fetch stream interrupted.", account_id);
|
||||
@@ -366,8 +488,9 @@ impl ImapExecutor {
|
||||
));
|
||||
}
|
||||
extract_envelope_and_store_it(fetch, account_id, mailbox_id).await?;
|
||||
count += 1;
|
||||
}
|
||||
Ok(())
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
/// Fetches the raw RFC822 body of a single message by UID.
|
||||
@@ -383,17 +506,17 @@ impl ImapExecutor {
|
||||
session
|
||||
.examine(encoded_mailbox_name)
|
||||
.await
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::ImapCommandFailed))?;
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), classify_imap_error(&e)))?;
|
||||
|
||||
let mut stream = session
|
||||
.uid_fetch(uid.to_string(), BODY_FETCH_COMMAND)
|
||||
.await
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::ImapCommandFailed))?;
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), classify_imap_error(&e)))?;
|
||||
|
||||
let fetch = stream
|
||||
.try_next()
|
||||
.await
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::ImapCommandFailed))?
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), classify_imap_error(&e)))?
|
||||
.ok_or_else(|| {
|
||||
raise_error!(
|
||||
format!("UID {uid} not found on IMAP server"),
|
||||
@@ -415,7 +538,7 @@ impl ImapExecutor {
|
||||
// while stream
|
||||
// .try_next()
|
||||
// .await
|
||||
// .map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::ImapCommandFailed))?
|
||||
// .map_err(|e| raise_error!(format!("{:#?}", e), classify_imap_error(&e)))?
|
||||
// .is_some()
|
||||
// {}
|
||||
|
||||
@@ -430,6 +553,7 @@ impl ImapExecutor {
|
||||
}
|
||||
|
||||
pub const DEFAULT_BATCH_SIZE: u32 = 30;
|
||||
pub const DEFAULT_MAX_EMAIL_SIZE: u64 = 100 * 1024 * 1024;
|
||||
|
||||
/// Compresses a sorted list of UIDs into an IMAP sequence-set string.
|
||||
/// Consecutive UIDs become ranges (e.g. `1:5`), non-consecutive are
|
||||
|
||||
@@ -4,7 +4,7 @@ use crate::{
|
||||
error::{code::ErrorCode, BichonResult},
|
||||
migrate::{
|
||||
legacy::schema::SchemaTools,
|
||||
store::{LegacyDirs, NewDirs, NewIndexWriter},
|
||||
store::{LegacyDirs, NewIndexWriter},
|
||||
},
|
||||
raise_error,
|
||||
settings::cli::SETTINGS,
|
||||
@@ -121,7 +121,7 @@ fn is_dir_not_empty(path: &PathBuf) -> std::io::Result<bool> {
|
||||
pub fn do_migrate_segment<F>(
|
||||
batch_size: u32,
|
||||
legacy: LegacyDirs,
|
||||
new_dirs: NewDirs,
|
||||
writer: &mut NewIndexWriter,
|
||||
segment_index: usize,
|
||||
mut on_progress: F,
|
||||
) -> BichonResult<()>
|
||||
@@ -226,8 +226,6 @@ where
|
||||
drop(envelope_index);
|
||||
|
||||
// ── Phase 2: process EML docs, streaming one at a time ─────────────
|
||||
let mut writer = NewIndexWriter::open(new_dirs)?;
|
||||
|
||||
let mut total_migrated = 0usize;
|
||||
let mut total_skipped = 0usize;
|
||||
|
||||
@@ -308,7 +306,6 @@ where
|
||||
chunk_start = chunk_end;
|
||||
}
|
||||
|
||||
writer.finish_writers()?;
|
||||
on_progress(&format!("DONE:{}:{}", total_migrated, total_skipped));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -50,10 +50,12 @@ use crate::{
|
||||
tokenizers::EuroTokenizer,
|
||||
},
|
||||
},
|
||||
utils::html::extract_text,
|
||||
utc_now,
|
||||
};
|
||||
|
||||
use chrono::Utc;
|
||||
use mail_parser::MessageParser;
|
||||
use serde_json::json;
|
||||
use tantivy::{
|
||||
aggregation::{
|
||||
@@ -1073,8 +1075,9 @@ impl IndexManager {
|
||||
let searcher = self.create_searcher()?;
|
||||
let mut writer = self.index_writer.lock().await;
|
||||
|
||||
let f_tags = SchemaTools::email_fields().f_tags;
|
||||
let f_id = SchemaTools::email_fields().f_id;
|
||||
let f = SchemaTools::email_fields();
|
||||
let f_tags = f.f_tags;
|
||||
let f_id = f.f_id;
|
||||
let deduplicated_updates: HashMap<u64, HashSet<String>> = request
|
||||
.updates
|
||||
.into_iter()
|
||||
@@ -1119,13 +1122,124 @@ impl IndexManager {
|
||||
|
||||
let mut new_doc = TantivyDocument::new();
|
||||
|
||||
// Copy stored fields, excluding f_tags (handled separately).
|
||||
for (field, value) in old_doc.field_values() {
|
||||
if field != f_tags {
|
||||
new_doc.add_field_value(field, value);
|
||||
}
|
||||
}
|
||||
for tag in current_tags {
|
||||
new_doc.add_facet(f_tags, &tag);
|
||||
|
||||
// Reconstruct non-stored text-search fields from their
|
||||
// stored counterparts. f_from_text / f_to_text / f_cc_text /
|
||||
// f_bcc_text carry the same content as f_from / f_to / f_cc / f_bcc.
|
||||
for val in old_doc.get_all(f.f_from) {
|
||||
if let Some(s) = val.as_str() {
|
||||
new_doc.add_text(f.f_from_text, s);
|
||||
}
|
||||
}
|
||||
for val in old_doc.get_all(f.f_to) {
|
||||
if let Some(s) = val.as_str() {
|
||||
new_doc.add_text(f.f_to_text, s);
|
||||
}
|
||||
}
|
||||
for val in old_doc.get_all(f.f_cc) {
|
||||
if let Some(s) = val.as_str() {
|
||||
new_doc.add_text(f.f_cc_text, s);
|
||||
}
|
||||
}
|
||||
for val in old_doc.get_all(f.f_bcc) {
|
||||
if let Some(s) = val.as_str() {
|
||||
new_doc.add_text(f.f_bcc_text, s);
|
||||
}
|
||||
}
|
||||
|
||||
// Reconstruct attachment-name fields from the stored
|
||||
// f_attachments JSON blob.
|
||||
if let Some(attrs_val) = old_doc.get_first(f.f_attachments) {
|
||||
if let Some(json_str) = attrs_val.as_str() {
|
||||
if let Ok(parsed) =
|
||||
serde_json::from_str::<serde_json::Value>(json_str)
|
||||
{
|
||||
if let Some(arr) = parsed.as_array() {
|
||||
for att in arr {
|
||||
let is_inline = att
|
||||
.get("inline")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false);
|
||||
let has_cid = att
|
||||
.get("content_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| !s.is_empty())
|
||||
.unwrap_or(false);
|
||||
if is_inline && has_cid {
|
||||
continue;
|
||||
}
|
||||
if let Some(filename) = att
|
||||
.get("filename")
|
||||
.and_then(|v| v.as_str())
|
||||
.filter(|s| !s.is_empty())
|
||||
{
|
||||
new_doc.add_text(
|
||||
f.f_attachment_name_text,
|
||||
filename,
|
||||
);
|
||||
new_doc.add_text(
|
||||
f.f_attachment_name_exact,
|
||||
filename,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Reconstruct body text from the original EML stored in the
|
||||
// blob store, referenced by f_content_hash.
|
||||
if let Some(hash_val) = old_doc.get_first(f.f_content_hash) {
|
||||
if let Some(content_hash) = hash_val.as_str() {
|
||||
match BLOB_MANAGER.get_email(content_hash) {
|
||||
Ok(Some(eml_bytes)) => {
|
||||
if let Some(message) =
|
||||
MessageParser::new().parse(&eml_bytes)
|
||||
{
|
||||
let text = message
|
||||
.body_text(0)
|
||||
.map(|cow| cow.into_owned())
|
||||
.or_else(|| {
|
||||
message.body_html(0).map(|cow| {
|
||||
extract_text(cow.into_owned())
|
||||
})
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let body_text = text
|
||||
.split_whitespace()
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
if !body_text.is_empty() {
|
||||
new_doc.add_text(f.f_body, &body_text);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(None) => {
|
||||
tracing::warn!(
|
||||
content_hash,
|
||||
"EML not found in blob store during tag update"
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
content_hash,
|
||||
error = %e,
|
||||
"Failed to fetch EML during tag update"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for tag in ¤t_tags {
|
||||
new_doc.add_facet(f_tags, tag);
|
||||
}
|
||||
|
||||
let delete_term = Term::from_field_text(f_id, eid);
|
||||
@@ -1190,7 +1304,7 @@ impl IndexManager {
|
||||
let mailbox_docs: Vec<DocAddress>;
|
||||
|
||||
match sort_by {
|
||||
SortBy::DATE => {
|
||||
SortBy::DATE => {
|
||||
let date_docs: Vec<(Option<i64>, DocAddress)> = searcher
|
||||
.search(
|
||||
&query,
|
||||
@@ -1568,3 +1682,378 @@ impl IndexManager {
|
||||
Ok(stats)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::store::tantivy::tokenizers::EuroTokenizer;
|
||||
use serde_json::json;
|
||||
use tantivy::{
|
||||
collector::Count,
|
||||
query::{QueryParser, TermQuery},
|
||||
schema::IndexRecordOption,
|
||||
Index, Term,
|
||||
};
|
||||
|
||||
/// Build a complete test document with known values across all
|
||||
/// stored and non-stored fields so we can verify reconstruction.
|
||||
fn build_test_doc() -> TantivyDocument {
|
||||
let f = SchemaTools::email_fields();
|
||||
let mut doc = TantivyDocument::new();
|
||||
|
||||
doc.add_text(f.f_id, "test-eid-001");
|
||||
doc.add_text(f.f_message_id, "<test@msg.id>");
|
||||
doc.add_u64(f.f_account_id, 1);
|
||||
doc.add_u64(f.f_mailbox_id, 10);
|
||||
doc.add_u64(f.f_uid, 100);
|
||||
doc.add_text(f.f_subject, "Test Subject Line");
|
||||
doc.add_text(f.f_body, "the quick brown fox jumps over the lazy dog");
|
||||
doc.add_text(f.f_preview, "the quick brown fox...");
|
||||
doc.add_text(f.f_content_hash, "test-content-hash-001");
|
||||
// f_from / f_from_text carry the same data
|
||||
doc.add_text(f.f_from, "alice@example.com");
|
||||
doc.add_text(f.f_from_text, "alice@example.com");
|
||||
doc.add_text(f.f_to, "bob@example.com");
|
||||
doc.add_text(f.f_to_text, "bob@example.com");
|
||||
doc.add_text(f.f_cc, "carol@example.com");
|
||||
doc.add_text(f.f_cc_text, "carol@example.com");
|
||||
doc.add_text(f.f_bcc, "dave@example.com");
|
||||
doc.add_text(f.f_bcc_text, "dave@example.com");
|
||||
doc.add_i64(f.f_date, 1_700_000_000_000);
|
||||
doc.add_i64(f.f_internal_date, 1_700_000_000_000);
|
||||
doc.add_i64(f.f_ingest_at, 1_700_000_000_000);
|
||||
doc.add_u64(f.f_size, 999);
|
||||
doc.add_text(f.f_thread_id, "thread-xyz");
|
||||
|
||||
// Attachment metadata (stored as JSON).
|
||||
let atts = json!([{
|
||||
"filename": "invoice.pdf",
|
||||
"file_type": "application/pdf",
|
||||
"inline": false,
|
||||
"size": 5000,
|
||||
"content_id": null,
|
||||
"content_hash": "att-hash-pdf",
|
||||
"is_message": false
|
||||
}]);
|
||||
doc.add_text(f.f_attachments, atts.to_string());
|
||||
doc.add_text(f.f_attachment_name_text, "invoice.pdf");
|
||||
doc.add_text(f.f_attachment_name_exact, "invoice.pdf");
|
||||
doc.add_text(f.f_attachment_ext, "pdf");
|
||||
doc.add_text(f.f_attachment_category, "document");
|
||||
doc.add_text(f.f_attachment_content_type, "application/pdf");
|
||||
doc.add_text(f.f_attachment_content_hash, "att-hash-pdf");
|
||||
doc.add_u64(f.f_attachment_count, 1);
|
||||
doc.add_u64(f.f_regular_attachment_count, 1);
|
||||
doc.add_u64(f.f_shard_id, 0);
|
||||
|
||||
// Initial tags.
|
||||
doc.add_facet(f.f_tags, "/inbox");
|
||||
doc.add_facet(f.f_tags, "/unread");
|
||||
|
||||
doc
|
||||
}
|
||||
|
||||
/// Reconstruct a new tantivy document from `old_doc`, preserving all
|
||||
/// fields (including non-stored ones) and replacing tags with
|
||||
/// `new_tags`. Body text is reconstructed from the supplied `eml_cache`
|
||||
/// (a stand-in for the blob store) rather than from
|
||||
/// `old_doc.field_values()` because `f_body` is not STORED.
|
||||
fn reconstruct_for_test(
|
||||
old_doc: &TantivyDocument,
|
||||
new_tags: &HashSet<String>,
|
||||
eml_cache: &HashMap<String, Vec<u8>>,
|
||||
) -> TantivyDocument {
|
||||
let f = SchemaTools::email_fields();
|
||||
let mut new_doc = TantivyDocument::new();
|
||||
|
||||
// ── stored fields (except f_tags) ──────────────────────────
|
||||
for (field, value) in old_doc.field_values() {
|
||||
if field != f.f_tags {
|
||||
new_doc.add_field_value(field, value);
|
||||
}
|
||||
}
|
||||
|
||||
// ── non-stored text-search fields ──────────────────────────
|
||||
for val in old_doc.get_all(f.f_from) {
|
||||
if let Some(s) = val.as_str() {
|
||||
new_doc.add_text(f.f_from_text, s);
|
||||
}
|
||||
}
|
||||
for val in old_doc.get_all(f.f_to) {
|
||||
if let Some(s) = val.as_str() {
|
||||
new_doc.add_text(f.f_to_text, s);
|
||||
}
|
||||
}
|
||||
for val in old_doc.get_all(f.f_cc) {
|
||||
if let Some(s) = val.as_str() {
|
||||
new_doc.add_text(f.f_cc_text, s);
|
||||
}
|
||||
}
|
||||
for val in old_doc.get_all(f.f_bcc) {
|
||||
if let Some(s) = val.as_str() {
|
||||
new_doc.add_text(f.f_bcc_text, s);
|
||||
}
|
||||
}
|
||||
|
||||
// ── attachment-name fields ─────────────────────────────────
|
||||
if let Some(attrs_val) = old_doc.get_first(f.f_attachments) {
|
||||
if let Some(json_str) = attrs_val.as_str() {
|
||||
if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(json_str) {
|
||||
if let Some(arr) = parsed.as_array() {
|
||||
for att in arr {
|
||||
let is_inline = att
|
||||
.get("inline")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false);
|
||||
let has_cid = att
|
||||
.get("content_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| !s.is_empty())
|
||||
.unwrap_or(false);
|
||||
if is_inline && has_cid {
|
||||
continue;
|
||||
}
|
||||
if let Some(filename) = att
|
||||
.get("filename")
|
||||
.and_then(|v| v.as_str())
|
||||
.filter(|s| !s.is_empty())
|
||||
{
|
||||
new_doc.add_text(f.f_attachment_name_text, filename);
|
||||
new_doc.add_text(f.f_attachment_name_exact, filename);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── body text (from eml cache – stands in for BLOB_MANAGER) ──
|
||||
if let Some(hash_val) = old_doc.get_first(f.f_content_hash) {
|
||||
if let Some(content_hash) = hash_val.as_str() {
|
||||
if let Some(eml_bytes) = eml_cache.get(content_hash) {
|
||||
if let Some(message) = MessageParser::new().parse(eml_bytes) {
|
||||
let text = message
|
||||
.body_text(0)
|
||||
.map(|cow| cow.into_owned())
|
||||
.or_else(|| {
|
||||
message
|
||||
.body_html(0)
|
||||
.map(|cow| extract_text(cow.into_owned()))
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let body_text =
|
||||
text.split_whitespace().collect::<Vec<_>>().join(" ");
|
||||
if !body_text.is_empty() {
|
||||
new_doc.add_text(f.f_body, &body_text);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── updated tags ───────────────────────────────────────────
|
||||
for tag in new_tags {
|
||||
new_doc.add_facet(f.f_tags, tag);
|
||||
}
|
||||
|
||||
new_doc
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_tags_preserves_non_stored_fields() {
|
||||
let f = SchemaTools::email_fields();
|
||||
|
||||
// ---- setup: in-memory index + document --------------------
|
||||
let index = Index::create_in_ram(SchemaTools::email_schema());
|
||||
index.tokenizers().register("euro", EuroTokenizer::new());
|
||||
|
||||
{
|
||||
let mut writer = index
|
||||
.writer_with_num_threads(1, 15_000_000)
|
||||
.expect("writer");
|
||||
let doc = build_test_doc();
|
||||
writer.add_document(doc).unwrap();
|
||||
writer.commit().unwrap();
|
||||
} // drop writer so the next one can acquire the lock
|
||||
|
||||
// ---- build a minimal EML so body reconstruction works ------
|
||||
let eml = b"From: alice@example.com\r\n\
|
||||
To: bob@example.com\r\n\
|
||||
Subject: Test\r\n\
|
||||
Date: Thu, 01 Jan 2023 00:00:00 +0000\r\n\
|
||||
Message-ID: <test@msg.id>\r\n\
|
||||
\r\n\
|
||||
the quick brown fox jumps over the lazy dog\r\n";
|
||||
let mut eml_cache = HashMap::new();
|
||||
eml_cache.insert("test-content-hash-001".to_string(), eml.to_vec());
|
||||
|
||||
// ---- read old doc, reconstruct, delete + add --------------
|
||||
let reader = index.reader().unwrap();
|
||||
reader.reload().unwrap();
|
||||
let searcher = reader.searcher();
|
||||
let query = TermQuery::new(
|
||||
Term::from_field_text(f.f_id, "test-eid-001"),
|
||||
IndexRecordOption::Basic,
|
||||
);
|
||||
let hits = searcher
|
||||
.search(&query, &TopDocs::with_limit(1).order_by_score())
|
||||
.unwrap();
|
||||
assert_eq!(hits.len(), 1);
|
||||
|
||||
let old_doc: TantivyDocument = searcher.doc(hits[0].1).unwrap();
|
||||
|
||||
let mut new_tags = HashSet::new();
|
||||
new_tags.insert("/important".to_string());
|
||||
new_tags.insert("/inbox".to_string());
|
||||
|
||||
let new_doc = reconstruct_for_test(&old_doc, &new_tags, &eml_cache);
|
||||
|
||||
let mut writer2 = index
|
||||
.writer_with_num_threads(1, 15_000_000)
|
||||
.expect("writer2");
|
||||
writer2
|
||||
.delete_term(Term::from_field_text(f.f_id, "test-eid-001"));
|
||||
writer2.add_document(new_doc).unwrap();
|
||||
writer2.commit().unwrap();
|
||||
|
||||
// ---- verify: search for non-stored fields still works -----
|
||||
let reader = index.reader().unwrap();
|
||||
reader.reload().unwrap();
|
||||
let searcher = reader.searcher();
|
||||
|
||||
// Body text (tokenized via "euro")
|
||||
let body_parser =
|
||||
QueryParser::for_index(&index, vec![f.f_body]);
|
||||
let body_hits = searcher
|
||||
.search(&body_parser.parse_query("quick brown fox").unwrap(), &Count)
|
||||
.unwrap();
|
||||
assert_eq!(body_hits, 1, "body text should survive tag update");
|
||||
|
||||
// from_text (tokenized via "euro")
|
||||
let from_parser =
|
||||
QueryParser::for_index(&index, vec![f.f_from_text]);
|
||||
let from_hits = searcher
|
||||
.search(
|
||||
&from_parser.parse_query("alice@example.com").unwrap(),
|
||||
&Count,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(from_hits, 1, "from_text should survive tag update");
|
||||
|
||||
// to_text
|
||||
let to_parser = QueryParser::for_index(&index, vec![f.f_to_text]);
|
||||
let to_hits = searcher
|
||||
.search(
|
||||
&to_parser.parse_query("bob@example.com").unwrap(),
|
||||
&Count,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(to_hits, 1, "to_text should survive tag update");
|
||||
|
||||
// attachment_name_exact (STRING — not tokenized)
|
||||
let att_hits = searcher
|
||||
.search(
|
||||
&TermQuery::new(
|
||||
Term::from_field_text(f.f_attachment_name_exact, "invoice.pdf"),
|
||||
IndexRecordOption::Basic,
|
||||
),
|
||||
&Count,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
att_hits, 1,
|
||||
"attachment_name_exact should survive tag update"
|
||||
);
|
||||
|
||||
// Updated tags
|
||||
let tags_hits = searcher
|
||||
.search(
|
||||
&TermQuery::new(
|
||||
Term::from_facet(
|
||||
f.f_tags,
|
||||
&Facet::from_text("/important").unwrap(),
|
||||
),
|
||||
IndexRecordOption::Basic,
|
||||
),
|
||||
&Count,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(tags_hits, 1, "new tag /important should be present");
|
||||
|
||||
// Old tag /unread should be gone since we overwrote with new_tags
|
||||
let old_tag_hits = searcher
|
||||
.search(
|
||||
&TermQuery::new(
|
||||
Term::from_facet(
|
||||
f.f_tags,
|
||||
&Facet::from_text("/unread").unwrap(),
|
||||
),
|
||||
IndexRecordOption::Basic,
|
||||
),
|
||||
&Count,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
old_tag_hits, 0,
|
||||
"old tag /unread should have been removed"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn body_reconstruction_from_eml_cache() {
|
||||
// Verify the EML → body_text extraction used inside
|
||||
// reconstruct_for_test (and therefore update_envelope_tags).
|
||||
let eml = b"From: x@y\r\n\
|
||||
Subject: testing\r\n\
|
||||
Date: Thu, 01 Jan 2023 00:00:00 +0000\r\n\
|
||||
\r\n\
|
||||
hello world from the test suite\r\n";
|
||||
|
||||
let mut cache = HashMap::new();
|
||||
cache.insert("hash-abc".to_string(), eml.to_vec());
|
||||
|
||||
let f = SchemaTools::email_fields();
|
||||
let mut old = TantivyDocument::new();
|
||||
old.add_text(f.f_content_hash, "hash-abc");
|
||||
|
||||
let reconstructed = reconstruct_for_test(&old, &HashSet::new(), &cache);
|
||||
|
||||
// The body should have been extracted from the EML and added
|
||||
// back to the document. Search for it.
|
||||
let index = Index::create_in_ram(SchemaTools::email_schema());
|
||||
index.tokenizers().register("euro", EuroTokenizer::new());
|
||||
let mut writer = index
|
||||
.writer_with_num_threads(1, 15_000_000)
|
||||
.expect("writer");
|
||||
writer.add_document(reconstructed).unwrap();
|
||||
writer.commit().unwrap();
|
||||
|
||||
let reader = index.reader().unwrap();
|
||||
reader.reload().unwrap();
|
||||
let searcher = reader.searcher();
|
||||
let parser = QueryParser::for_index(&index, vec![f.f_body]);
|
||||
let hits = searcher
|
||||
.search(&parser.parse_query("hello world").unwrap(), &Count)
|
||||
.unwrap();
|
||||
assert_eq!(hits, 1, "body text should be reconstructed from EML");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn body_reconstruction_missing_eml_is_graceful() {
|
||||
// When the EML is not in the cache (simulating a blob-store
|
||||
// miss), the document should still be produced without body.
|
||||
let f = SchemaTools::email_fields();
|
||||
let mut old = TantivyDocument::new();
|
||||
old.add_text(f.f_content_hash, "nonexistent-hash");
|
||||
|
||||
let cache = HashMap::new(); // empty
|
||||
let reconstructed = reconstruct_for_test(&old, &HashSet::new(), &cache);
|
||||
|
||||
// The document exists but has no body field.
|
||||
let body_vals: Vec<_> = reconstructed.get_all(f.f_body).collect();
|
||||
assert!(
|
||||
body_vals.is_empty(),
|
||||
"body should be absent when EML is missing"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
{
|
||||
"$schema": "https://fundingjson.org/schema/v1.1.0.json",
|
||||
"version": "v1.0.0",
|
||||
"entity": {
|
||||
"type": "individual",
|
||||
"role": "maintainer",
|
||||
"name": "rustmailer",
|
||||
"email": "rustmailer.git@gmail.com",
|
||||
"phone": "",
|
||||
"description": "I'm an indie developer and the sole maintainer of Bichon, a lightweight open-source email archiver built in Rust. I believe in privacy, data ownership, and the right to self-host your own digital life.",
|
||||
"webpageUrl": {
|
||||
"url": "https://github.com/rustmailer"
|
||||
}
|
||||
},
|
||||
"projects": [
|
||||
{
|
||||
"guid": "bichon",
|
||||
"name": "Bichon",
|
||||
"description": "Bichon is a lightweight, high-performance, self-hosted email archiver built in Rust. It synchronizes emails from IMAP servers, indexes them for full-text search, and provides a clean WebUI and REST API for access.\n\nBichon requires no external database and runs as a single binary — making it easy to deploy and maintain. It supports multiple accounts, OAuth2, SOCKS5 proxy, scheduled sync, bulk import (EML/MBOX/PST), and multi-user RBAC.\n\nAs the sole maintainer, I develop and support Bichon in my personal time. With 1.8k GitHub stars and 327k+ Docker pulls, the project has grown well beyond a personal tool and is actively used by individuals and teams worldwide — including a real-world deployment archiving 1.15 million emails across 28 accounts (800 GB original data, compressed to 421 GB on disk).",
|
||||
"webpageUrl": {
|
||||
"url": "https://github.com/rustmailer/bichon"
|
||||
},
|
||||
"repositoryUrl": {
|
||||
"url": "https://github.com/rustmailer/bichon"
|
||||
},
|
||||
"licenses": ["spdx:AGPL-3.0"],
|
||||
"tags": ["email", "rust", "self-hosted", "archiver", "imap", "full-text-search", "privacy", "webui"]
|
||||
}
|
||||
],
|
||||
"funding": {
|
||||
"channels": [
|
||||
{
|
||||
"guid": "buymeacoffee",
|
||||
"type": "payment-provider",
|
||||
"address": "https://buymeacoffee.com/rustmailer",
|
||||
"description": "Support via Buy Me a Coffee."
|
||||
},
|
||||
{
|
||||
"guid": "bank",
|
||||
"type": "bank",
|
||||
"address": "",
|
||||
"description": "Direct bank transfer also accepted. Please email rustmailer.git@gmail.com for details."
|
||||
}
|
||||
],
|
||||
"plans": [
|
||||
{
|
||||
"guid": "maintainer-time",
|
||||
"status": "active",
|
||||
"name": "Maintainer Time",
|
||||
"description": "Cover the cost of dedicated development and maintenance time for Bichon — including bug fixes, feature development, security updates, issue triage, and community support.",
|
||||
"amount": 10000,
|
||||
"currency": "USD",
|
||||
"frequency": "yearly",
|
||||
"channels": ["bank"]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -131,6 +131,7 @@ export interface AccountModel {
|
||||
download_folders: string[];
|
||||
download_interval_min?: number;
|
||||
download_batch_size?: number;
|
||||
max_email_size_bytes?: number;
|
||||
created_by: number;
|
||||
created_user_name: string;
|
||||
created_user_email: string;
|
||||
@@ -143,6 +144,7 @@ export interface AccountModel {
|
||||
imap_quota_bytes?: number;
|
||||
auto_download_new_mailboxes?: boolean;
|
||||
download_schedule?: string;
|
||||
deleting?: boolean;
|
||||
}
|
||||
|
||||
export const download_state = async (account_id: number) => {
|
||||
|
||||
@@ -28,18 +28,54 @@ interface GithubLinkButtonProps {
|
||||
title?: string;
|
||||
}
|
||||
|
||||
const CACHE_KEY = "github_stars_cache";
|
||||
const CACHE_TTL = 6 * 60 * 60 * 1000; // 6 hours
|
||||
|
||||
interface StarsCache {
|
||||
stars: number;
|
||||
fetchedAt: number;
|
||||
}
|
||||
|
||||
function getCachedStars(repo: string): number | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(`${CACHE_KEY}_${repo}`);
|
||||
if (!raw) return null;
|
||||
const cache: StarsCache = JSON.parse(raw);
|
||||
if (Date.now() - cache.fetchedAt > CACHE_TTL) return null;
|
||||
return cache.stars;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function setCachedStars(repo: string, stars: number) {
|
||||
try {
|
||||
localStorage.setItem(
|
||||
`${CACHE_KEY}_${repo}`,
|
||||
JSON.stringify({ stars, fetchedAt: Date.now() })
|
||||
);
|
||||
} catch { }
|
||||
}
|
||||
|
||||
export const GithubLinkButton: React.FC<GithubLinkButtonProps> = ({
|
||||
href = "https://github.com/rustmailer/bichon",
|
||||
repo = "rustmailer/bichon",
|
||||
size = 18,
|
||||
title = "View on GitHub",
|
||||
}) => {
|
||||
const [stars, setStars] = useState<number | null>(null);
|
||||
const [stars, setStars] = useState<number | null>(() => getCachedStars(repo));
|
||||
|
||||
useEffect(() => {
|
||||
if (stars !== null) return; // already have cached value, skip fetch
|
||||
fetch(`https://api.github.com/repos/${repo}`)
|
||||
.then(res => res.json())
|
||||
.then(data => setStars(data.stargazers_count))
|
||||
.then(data => {
|
||||
const count = data.stargazers_count;
|
||||
if (typeof count === "number") {
|
||||
setStars(count);
|
||||
setCachedStars(repo, count);
|
||||
}
|
||||
})
|
||||
.catch(() => { });
|
||||
}, [repo]);
|
||||
|
||||
|
||||
@@ -97,6 +97,10 @@ export function AccountDetailDrawer({ open, onOpenChange, currentRow }: Props) {
|
||||
<span className="text-muted-foreground">{t('accounts.downloadBatchSize')}:</span>
|
||||
<span>{currentRow.download_batch_size}</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-muted-foreground">{t('accounts.maxEmailSizeBytes')}:</span>
|
||||
<span>{currentRow.max_email_size_bytes ? `${(currentRow.max_email_size_bytes / 1024 / 1024).toFixed(0)} MB` : t('accounts.maxEmailSizeBytesUnlimited')}</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="text-muted-foreground">{t('accounts.capabilities')}:</span>
|
||||
<code className="rounded-md bg-muted/50 px-2 py-1 text-sm border overflow-x-auto inline-block">
|
||||
|
||||
@@ -49,7 +49,7 @@ export type Steps = [...Step[]];
|
||||
const getSteps = (t: (key: string) => string): Steps => [
|
||||
{ id: "step-1", name: t('accounts.steps.emailAddress'), fields: ["email", "account_name"] },
|
||||
{ id: "step-2", name: t('accounts.steps.imap'), fields: ["imap", "use_dangerous", "login_name"] },
|
||||
{ id: "step-3", name: t('accounts.steps.syncPreferences'), fields: ["enabled", "date_since", "date_before", "download_interval_min", "download_batch_size", "auto_download_new_mailboxes", "download_schedule"] },
|
||||
{ id: "step-3", name: t('accounts.steps.syncPreferences'), fields: ["enabled", "date_since", "date_before", "download_interval_min", "download_batch_size", "max_email_size_bytes", "auto_download_new_mailboxes", "download_schedule"] },
|
||||
{ id: "step-4", name: t('accounts.steps.summary'), fields: [] },
|
||||
];
|
||||
|
||||
@@ -81,6 +81,7 @@ const defaultValues: Account = {
|
||||
date_before: undefined,
|
||||
download_interval_min: 60,
|
||||
download_batch_size: 30,
|
||||
max_email_size_bytes: 100 * 1024 * 1024,
|
||||
auto_download_new_mailboxes: true,
|
||||
download_schedule: undefined,
|
||||
};
|
||||
@@ -111,6 +112,7 @@ const mapCurrentRowToFormValues = (currentRow: AccountModel): Account => {
|
||||
date_before: currentRow.date_before ?? undefined,
|
||||
download_interval_min: currentRow.download_interval_min ?? 60,
|
||||
download_batch_size: currentRow.download_batch_size ?? 30,
|
||||
max_email_size_bytes: currentRow.max_email_size_bytes ?? 100 * 1024 * 1024,
|
||||
auto_download_new_mailboxes: currentRow.auto_download_new_mailboxes ?? true,
|
||||
download_schedule: currentRow.download_schedule ?? undefined,
|
||||
};
|
||||
@@ -193,6 +195,7 @@ export function AccountActionDialog({ currentRow, open, onOpenChange }: Props) {
|
||||
date_before: data.date_before,
|
||||
download_interval_min: data.download_interval_min,
|
||||
download_batch_size: data.download_batch_size,
|
||||
max_email_size_bytes: data.max_email_size_bytes,
|
||||
auto_download_new_mailboxes: data.auto_download_new_mailboxes,
|
||||
download_schedule: data.download_schedule || null,
|
||||
};
|
||||
|
||||
@@ -50,13 +50,16 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) {
|
||||
const hasPermission = require_any_permission(['system:root', 'account:manage'], row.original.id);
|
||||
const hasReadPermission = require_any_permission(['system:root', 'account:read_details'], row.original.id);
|
||||
|
||||
const isDeleting = row.original.deleting === true;
|
||||
|
||||
const canShowAnyAction =
|
||||
!isDeleting && (
|
||||
(hasPermission) ||
|
||||
(account_type === 'IMAP' && hasPermission) ||
|
||||
(account_type === 'IMAP' && hasReadPermission);
|
||||
(account_type === 'IMAP' && hasReadPermission)
|
||||
);
|
||||
|
||||
const showDownload = account_type === 'IMAP' && hasPermission;
|
||||
const showDownload = !isDeleting && account_type === 'IMAP' && hasPermission;
|
||||
|
||||
const handleStartDownload = async () => {
|
||||
try {
|
||||
|
||||
@@ -43,8 +43,8 @@ export function AccountDeleteDialog({ open, onOpenChange, currentRow }: Props) {
|
||||
const queryClient = useQueryClient();
|
||||
function handleSuccess() {
|
||||
toast({
|
||||
title: t('dialogs.accountDeleted'),
|
||||
description: t('dialogs.accountDeletedDesc'),
|
||||
title: t('dialogs.accountDeletionStarted'),
|
||||
description: t('dialogs.accountDeletionStartedDesc'),
|
||||
action: <ToastAction altText={t('common.close')}>{t('common.close')}</ToastAction>,
|
||||
});
|
||||
|
||||
|
||||
@@ -77,7 +77,7 @@ export function EnableAction({ row }: DataTableRowActionsProps) {
|
||||
<Switch
|
||||
checked={row.original.enabled}
|
||||
onCheckedChange={() => setOpen(true)}
|
||||
disabled={!hasPermission || updateMutation.isPending}
|
||||
disabled={!hasPermission || updateMutation.isPending || row.original.deleting}
|
||||
/>
|
||||
<ConfirmDialog
|
||||
open={open}
|
||||
|
||||
@@ -35,6 +35,9 @@ export function RunningStateCellAction({ row }: Props) {
|
||||
const { setOpen, setCurrentRow } = useAccountContext()
|
||||
const { require_any_permission } = useCurrentUser()
|
||||
|
||||
if (row.original.deleting) {
|
||||
return <span className="text-xs text-muted-foreground italic">Deleting...</span>
|
||||
}
|
||||
let account_type = row.original.account_type;
|
||||
if (account_type === "NoSync") {
|
||||
return <span className="text-xs text-muted-foreground">n/a</span>
|
||||
|
||||
@@ -100,6 +100,13 @@ export const getAccountSchema = (isEdit: boolean, t: (key: string) => string) =>
|
||||
.max(200, {
|
||||
message: t('validation.singleRequestBatchSizeTooLarge'),
|
||||
}),
|
||||
max_email_size_bytes: z
|
||||
.number({
|
||||
invalid_type_error: t('validation.maxEmailSizeMustBeNumber'),
|
||||
})
|
||||
.int()
|
||||
.min(1 * 1024 * 1024, { message: t('validation.maxEmailSizeTooSmall') })
|
||||
.max(100 * 1024 * 1024, { message: t('validation.maxEmailSizeTooLarge') }),
|
||||
auto_download_new_mailboxes: z.boolean(),
|
||||
download_schedule: z
|
||||
.string()
|
||||
|
||||
@@ -392,6 +392,38 @@ export default function Step3() {
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={control}
|
||||
name="max_email_size_bytes"
|
||||
render={({ field }) => {
|
||||
const BYTES_PER_MB = 1024 * 1024;
|
||||
|
||||
return (
|
||||
<FormItem>
|
||||
<FormLabel>{t('accounts.maxEmailSizeBytes')}</FormLabel>
|
||||
<FormControl>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
type="number"
|
||||
placeholder={t('accounts.maxEmailSizeBytesPlaceholder')}
|
||||
className="flex-1"
|
||||
value={field.value ? field.value / BYTES_PER_MB : ''}
|
||||
onChange={(e) => {
|
||||
const parsed = parseInt(e.target.value, 10);
|
||||
field.onChange(isNaN(parsed) ? parsed : parsed * BYTES_PER_MB);
|
||||
}}
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground whitespace-nowrap">MB</span>
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
<FormDescription>
|
||||
{t('accounts.maxEmailSizeBytesDescription')}
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -50,7 +50,11 @@ export default function Step4() {
|
||||
|
||||
return (
|
||||
<div className="rounded-xl">
|
||||
<Accordion type="multiple" defaultValue={['email', 'account_name', 'login_name', 'imap', 'date_since', 'sync_interval', 'sync_scope', 'sync_batch_size', 'download_schedule']}>
|
||||
<Accordion type="multiple" defaultValue={[
|
||||
'email', 'account_name', 'login_name', 'imap', 'date_since',
|
||||
'max_email_size_bytes', 'sync_interval', 'sync_scope',
|
||||
'sync_batch_size', 'download_schedule'
|
||||
]}>
|
||||
<AccordionItem key="email" value="email">
|
||||
<AccordionTrigger className="font-medium capitalize text-gray-600">{t('accounts.email')}:</AccordionTrigger>
|
||||
<AccordionContent>{summaryData.email}</AccordionContent>
|
||||
@@ -164,6 +168,11 @@ export default function Step4() {
|
||||
<AccordionContent>{summaryData.download_batch_size}</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem key="max_email_size_bytes" value="max_email_size_bytes">
|
||||
<AccordionTrigger className="font-medium capitalize text-gray-600">{t('accounts.maxEmailSizeBytes')}:</AccordionTrigger>
|
||||
<AccordionContent>{summaryData.max_email_size_bytes ? `${(summaryData.max_email_size_bytes / 1024 / 1024).toFixed(0)} MB` : t('accounts.maxEmailSizeBytesUnlimited')}</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem key="download_schedule" value="download_schedule">
|
||||
<AccordionTrigger className="font-medium capitalize text-gray-600">{t('accounts.downloadSchedule')}:</AccordionTrigger>
|
||||
<AccordionContent>{summaryData.download_schedule || t('accounts.notAvailable')}</AccordionContent>
|
||||
|
||||
@@ -127,7 +127,7 @@ export function AccountTable({ columns, data }: DataTableProps) {
|
||||
<TableRow
|
||||
key={row.id}
|
||||
data-state={row.getIsSelected() && 'selected'}
|
||||
className='group/row'
|
||||
className={row.original.deleting ? 'opacity-50' : 'group/row'}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell
|
||||
|
||||
@@ -55,6 +55,10 @@ export default function Accounts() {
|
||||
const { data: accountList, isLoading } = useQuery({
|
||||
queryKey: ['account-list'],
|
||||
queryFn: list_accounts,
|
||||
refetchInterval: (query) => {
|
||||
const items = (query.state.data as { items?: { deleting?: boolean }[] })?.items;
|
||||
return items?.some((item) => item.deleting) ? 5000 : false;
|
||||
},
|
||||
})
|
||||
|
||||
const hasAccounts = accountList != null && accountList.items.length > 0;
|
||||
|
||||
+10
-1
@@ -208,6 +208,10 @@
|
||||
"leaveEmptyToKeepExisting": "اترك فارغًا للاحتفاظ بكلمة المرور الحالية، أو أدخل كلمة مرور جديدة لتحديثها.",
|
||||
"leaveEmptyToKeepPassword": "اترك فارغًا للاحتفاظ بكلمة المرور الحالية",
|
||||
"login_name": "اسم الدخول",
|
||||
"maxEmailSizeBytes": "الحد الأقصى لحجم البريد",
|
||||
"maxEmailSizeBytesDescription": "سيتم تخطي الرسائل الأكبر من هذا الحجم. اتركه فارغاً لاستخدام الحد الافتراضي (100 ميجابايت).",
|
||||
"maxEmailSizeBytesPlaceholder": "الافتراضي: 100 ميجابايت",
|
||||
"maxEmailSizeBytesUnlimited": "الافتراضي: 100 ميجابايت",
|
||||
"minutes": "دقائق",
|
||||
"months": "أشهر",
|
||||
"mustBeAtLeast1": "يجب أن يكون 1 على الأقل",
|
||||
@@ -532,6 +536,8 @@
|
||||
"accountDeleteFailed": "فشل حذف الحساب",
|
||||
"accountDeleted": "تم حذف الحساب",
|
||||
"accountDeletedDesc": "تم حذف حسابك بنجاح.",
|
||||
"accountDeletionStarted": "بدء حذف الحساب",
|
||||
"accountDeletionStartedDesc": "جاري حذف الحساب في الخلفية، وسيختفي بعد اكتمال التنظيف.",
|
||||
"allResourcesErased": "سيتم مسح جميع الموارد ذات الصلة نهائيًا.",
|
||||
"cannotBeUndone": "لا يمكن التراجع عن هذا الإجراء!",
|
||||
"confirmDelete": "تأكيد الحذف",
|
||||
@@ -1681,6 +1687,9 @@
|
||||
"invalidCronExpression": "Invalid cron expression. Must be 6 fields: second minute hour day-of-month month day-of-week (e.g. '0 0 0 * * *')",
|
||||
"invalidEmail": "عنوان بريد إلكتروني غير صالح",
|
||||
"invalidUrl": "عنوان URL غير صالح",
|
||||
"maxEmailSizeMustBeNumber": "يجب أن يكون الحد الأقصى لحجم البريد رقماً.",
|
||||
"maxEmailSizeTooLarge": "يجب ألا يتجاوز الحد الأقصى لحجم البريد 100 ميجابايت.",
|
||||
"maxEmailSizeTooSmall": "يجب أن يكون الحد الأقصى لحجم البريد 1 ميجابايت على الأقل.",
|
||||
"passwordMinLength": "يجب أن تتكون كلمة المرور من {{min}} أحرف على الأقل",
|
||||
"passwordRequired": "كلمة المرور مطلوبة عندما تكون طريقة المصادقة هي كلمة المرور",
|
||||
"pleaseEnterPassword": "الرجاء إدخال كلمة المرور الخاصة بك",
|
||||
@@ -1690,4 +1699,4 @@
|
||||
"singleRequestBatchSizeTooLarge": "يجب أن يكون حجم الدُفعة على الأكثر 200",
|
||||
"singleRequestBatchSizeTooSmall": "يجب أن يكون حجم الدُفعة على الأقل 10"
|
||||
}
|
||||
}
|
||||
}
|
||||
+10
-1
@@ -208,6 +208,10 @@
|
||||
"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",
|
||||
"login_name": "Logindnavn",
|
||||
"maxEmailSizeBytes": "Maks. e-mailstørrelse",
|
||||
"maxEmailSizeBytesDescription": "E-mails større end dette vil blive oversprunget. Lad være tom for at bruge standarden (100 MB).",
|
||||
"maxEmailSizeBytesPlaceholder": "Standard: 100 MB",
|
||||
"maxEmailSizeBytesUnlimited": "Standard: 100 MB",
|
||||
"minutes": "minutter",
|
||||
"months": "Måneder",
|
||||
"mustBeAtLeast1": "Skal være mindst 1",
|
||||
@@ -532,6 +536,8 @@
|
||||
"accountDeleteFailed": "Sletning af konto mislykkedes",
|
||||
"accountDeleted": "Konto slettet",
|
||||
"accountDeletedDesc": "Din konto er blevet slettet.",
|
||||
"accountDeletionStarted": "Kontoen slettes nu",
|
||||
"accountDeletionStartedDesc": "Kontoen slettes i baggrunden og forsvinder, når oprydningen er færdig.",
|
||||
"allResourcesErased": "Alle relaterede ressourcer vil blive slettet permanent.",
|
||||
"cannotBeUndone": "Denne handling kan ikke fortrydes!",
|
||||
"confirmDelete": "Bekræft Sletning",
|
||||
@@ -1681,6 +1687,9 @@
|
||||
"invalidCronExpression": "Invalid cron expression. Must be 6 fields: second minute hour day-of-month month day-of-week (e.g. '0 0 0 * * *')",
|
||||
"invalidEmail": "Ugyldig e-mailadresse",
|
||||
"invalidUrl": "Ugyldig URL",
|
||||
"maxEmailSizeMustBeNumber": "Maks. e-mailstørrelse skal være et tal.",
|
||||
"maxEmailSizeTooLarge": "Maks. e-mailstørrelse må ikke overstige 100 MB.",
|
||||
"maxEmailSizeTooSmall": "Maks. e-mailstørrelse skal være mindst 1 MB.",
|
||||
"passwordMinLength": "Adgangskoden skal være mindst {{min}} tegn lang",
|
||||
"passwordRequired": "Adgangskode er påkrævet, når godkendelsesmetoden er Adgangskode",
|
||||
"pleaseEnterPassword": "Indtast venligst din adgangskode",
|
||||
@@ -1690,4 +1699,4 @@
|
||||
"singleRequestBatchSizeTooLarge": "Batch‑størrelse skal være højst 200",
|
||||
"singleRequestBatchSizeTooSmall": "Batch‑størrelse skal være mindst 10"
|
||||
}
|
||||
}
|
||||
}
|
||||
+10
-1
@@ -208,6 +208,10 @@
|
||||
"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",
|
||||
"login_name": "Anmeldename",
|
||||
"maxEmailSizeBytes": "Max. E-Mail-Größe",
|
||||
"maxEmailSizeBytesDescription": "Größere E-Mails werden übersprungen. Leer lassen, um den Standardwert (100 MB) zu verwenden.",
|
||||
"maxEmailSizeBytesPlaceholder": "Standard: 100 MB",
|
||||
"maxEmailSizeBytesUnlimited": "Standard: 100 MB",
|
||||
"minutes": "Minuten",
|
||||
"months": "Monate",
|
||||
"mustBeAtLeast1": "Muss mindestens 1 sein",
|
||||
@@ -532,6 +536,8 @@
|
||||
"accountDeleteFailed": "Löschen des Kontos fehlgeschlagen",
|
||||
"accountDeleted": "Konto gelöscht",
|
||||
"accountDeletedDesc": "Ihr Konto wurde erfolgreich gelöscht.",
|
||||
"accountDeletionStarted": "Kontolöschung gestartet",
|
||||
"accountDeletionStartedDesc": "Konto wird im Hintergrund gelöscht und verschwindet nach der Bereinigung.",
|
||||
"allResourcesErased": "Alle zugehörigen Ressourcen werden dauerhaft gelöscht.",
|
||||
"cannotBeUndone": "Diese Aktion kann nicht rückgängig gemacht werden!",
|
||||
"confirmDelete": "Löschung bestätigen",
|
||||
@@ -1681,6 +1687,9 @@
|
||||
"invalidCronExpression": "Invalid cron expression. Must be 6 fields: second minute hour day-of-month month day-of-week (e.g. '0 0 0 * * *')",
|
||||
"invalidEmail": "Ungültige E-Mail-Adresse",
|
||||
"invalidUrl": "Ungültige URL",
|
||||
"maxEmailSizeMustBeNumber": "Die maximale E-Mail-Größe muss eine Zahl sein.",
|
||||
"maxEmailSizeTooLarge": "Die maximale E-Mail-Größe darf 100 MB nicht überschreiten.",
|
||||
"maxEmailSizeTooSmall": "Die maximale E-Mail-Größe muss mindestens 1 MB betragen.",
|
||||
"passwordMinLength": "Das Passwort muss mindestens {{min}} Zeichen lang sein",
|
||||
"passwordRequired": "Passwort ist erforderlich, wenn die Authentifizierungsmethode Passwort ist",
|
||||
"pleaseEnterPassword": "Bitte geben Sie Ihr Passwort ein",
|
||||
@@ -1690,4 +1699,4 @@
|
||||
"singleRequestBatchSizeTooLarge": "Die Stapelgröße darf höchstens 200 sein",
|
||||
"singleRequestBatchSizeTooSmall": "Die Stapelgröße muss mindestens 10 sein"
|
||||
}
|
||||
}
|
||||
}
|
||||
+10
-1
@@ -208,6 +208,10 @@
|
||||
"leaveEmptyToKeepExisting": "Leave empty to keep the existing password, or enter a new password to update it.",
|
||||
"leaveEmptyToKeepPassword": "Leave empty to keep current password",
|
||||
"login_name": "Login Name",
|
||||
"maxEmailSizeBytes": "Max email size",
|
||||
"maxEmailSizeBytesDescription": "Emails larger than this will be skipped. Leave empty to use the default (100 MB).",
|
||||
"maxEmailSizeBytesPlaceholder": "Default: 100 MB",
|
||||
"maxEmailSizeBytesUnlimited": "Default: 100 MB",
|
||||
"minutes": "minutes",
|
||||
"months": "Months",
|
||||
"mustBeAtLeast1": "Must be at least 1",
|
||||
@@ -532,6 +536,8 @@
|
||||
"accountDeleteFailed": "Account delete Failed",
|
||||
"accountDeleted": "Account Deleted",
|
||||
"accountDeletedDesc": "Your account has been successfully deleted.",
|
||||
"accountDeletionStarted": "Account deletion started",
|
||||
"accountDeletionStartedDesc": "Account is being deleted in the background and will disappear after cleanup.",
|
||||
"allResourcesErased": "All related resources will be permanently erased.",
|
||||
"cannotBeUndone": "This action cannot be undone!",
|
||||
"confirmDelete": "Confirm Delete",
|
||||
@@ -1681,6 +1687,9 @@
|
||||
"invalidCronExpression": "Invalid cron expression. Must be 6 fields: second minute hour day-of-month month day-of-week (e.g. '0 0 0 * * *')",
|
||||
"invalidEmail": "Invalid email address",
|
||||
"invalidUrl": "Invalid URL",
|
||||
"maxEmailSizeMustBeNumber": "Max email size must be a number.",
|
||||
"maxEmailSizeTooLarge": "Max email size must not exceed 100 MB.",
|
||||
"maxEmailSizeTooSmall": "Max email size must be at least 1 MB.",
|
||||
"passwordMinLength": "Password must be at least {{min}} characters long",
|
||||
"passwordRequired": "Password is required when auth method is Password",
|
||||
"pleaseEnterPassword": "Please enter your password",
|
||||
@@ -1690,4 +1699,4 @@
|
||||
"singleRequestBatchSizeTooLarge": "Batch size must be at most 200",
|
||||
"singleRequestBatchSizeTooSmall": "Batch size must be at least 10"
|
||||
}
|
||||
}
|
||||
}
|
||||
+10
-1
@@ -208,6 +208,10 @@
|
||||
"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",
|
||||
"login_name": "Nombre de usuario",
|
||||
"maxEmailSizeBytes": "Tamaño máx. de correo",
|
||||
"maxEmailSizeBytesDescription": "Se omitirán los correos más grandes. Déjelo vacío para usar el valor predeterminado (100 MB).",
|
||||
"maxEmailSizeBytesPlaceholder": "Predeterminado: 100 MB",
|
||||
"maxEmailSizeBytesUnlimited": "Predeterminado: 100 MB",
|
||||
"minutes": "minutos",
|
||||
"months": "Meses",
|
||||
"mustBeAtLeast1": "Debe ser al menos 1",
|
||||
@@ -532,6 +536,8 @@
|
||||
"accountDeleteFailed": "Error al eliminar la cuenta",
|
||||
"accountDeleted": "Cuenta eliminada",
|
||||
"accountDeletedDesc": "Tu cuenta ha sido eliminada con éxito.",
|
||||
"accountDeletionStarted": "Eliminación de cuenta iniciada",
|
||||
"accountDeletionStartedDesc": "La cuenta se está eliminando en segundo plano y desaparecerá tras la limpieza.",
|
||||
"allResourcesErased": "Todos los recursos asociados se borrarán permanentemente.",
|
||||
"cannotBeUndone": "¡Esta acción no se puede deshacer!",
|
||||
"confirmDelete": "Confirmar eliminación",
|
||||
@@ -1681,6 +1687,9 @@
|
||||
"invalidCronExpression": "Invalid cron expression. Must be 6 fields: second minute hour day-of-month month day-of-week (e.g. '0 0 0 * * *')",
|
||||
"invalidEmail": "Dirección de correo electrónico inválida",
|
||||
"invalidUrl": "URL inválida",
|
||||
"maxEmailSizeMustBeNumber": "El tamaño máximo de correo debe ser un número.",
|
||||
"maxEmailSizeTooLarge": "El tamaño máximo de correo no debe superar los 100 MB.",
|
||||
"maxEmailSizeTooSmall": "El tamaño máximo de correo debe ser de al menos 1 MB.",
|
||||
"passwordMinLength": "La contraseña debe tener al menos {{min}} caracteres",
|
||||
"passwordRequired": "La contraseña es obligatoria cuando el método de autenticación es Contraseña",
|
||||
"pleaseEnterPassword": "Por favor, introduce tu contraseña",
|
||||
@@ -1690,4 +1699,4 @@
|
||||
"singleRequestBatchSizeTooLarge": "El tamaño del lote debe ser como máximo 200",
|
||||
"singleRequestBatchSizeTooSmall": "El tamaño del lote debe ser al menos 10"
|
||||
}
|
||||
}
|
||||
}
|
||||
+10
-1
@@ -208,6 +208,10 @@
|
||||
"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",
|
||||
"login_name": "Kirjautumisnimi",
|
||||
"maxEmailSizeBytes": "Sähköpostin maksimikoko",
|
||||
"maxEmailSizeBytesDescription": "Tätä suuremmat sähköpostit ohitetaan. Jätä tyhjäksi käyttääksesi oletusarvoa (100 MB).",
|
||||
"maxEmailSizeBytesPlaceholder": "Oletus: 100 MB",
|
||||
"maxEmailSizeBytesUnlimited": "Oletus: 100 MB",
|
||||
"minutes": "minuuttia",
|
||||
"months": "Kuukautta",
|
||||
"mustBeAtLeast1": "Täytyy olla vähintään 1",
|
||||
@@ -532,6 +536,8 @@
|
||||
"accountDeleteFailed": "Tilin poistaminen epäonnistui",
|
||||
"accountDeleted": "Tili poistettu",
|
||||
"accountDeletedDesc": "Tilisi on poistettu onnistuneesti.",
|
||||
"accountDeletionStarted": "Tilin poistaminen aloitettu",
|
||||
"accountDeletionStartedDesc": "Tiliä poistetaan taustalla. Se katoaa, kun puhdistus on valmis.",
|
||||
"allResourcesErased": "Kaikki liittyvät resurssit poistetaan pysyvästi.",
|
||||
"cannotBeUndone": "Tätä toimenpidettä ei voi kumota!",
|
||||
"confirmDelete": "Vahvista poisto",
|
||||
@@ -1681,6 +1687,9 @@
|
||||
"invalidCronExpression": "Invalid cron expression. Must be 6 fields: second minute hour day-of-month month day-of-week (e.g. '0 0 0 * * *')",
|
||||
"invalidEmail": "Virheellinen sähköpostiosoite",
|
||||
"invalidUrl": "Virheellinen URL-osoite",
|
||||
"maxEmailSizeMustBeNumber": "Sähköpostin maksimikoon on oltava numero.",
|
||||
"maxEmailSizeTooLarge": "Sähköpostin maksimikoko ei saa ylittää 100 megatavua.",
|
||||
"maxEmailSizeTooSmall": "Sähköpostin maksimikoon on oltava vähintään 1 MB.",
|
||||
"passwordMinLength": "Salasanan on oltava vähintään {{min}} merkkiä pitkä",
|
||||
"passwordRequired": "Salasana on pakollinen, kun todennusmenetelmä on Salasana",
|
||||
"pleaseEnterPassword": "Syötä salasanasi",
|
||||
@@ -1690,4 +1699,4 @@
|
||||
"singleRequestBatchSizeTooLarge": "Eräkoko tulee olla enintään 200",
|
||||
"singleRequestBatchSizeTooSmall": "Eräkoko tulee olla vähintään 10"
|
||||
}
|
||||
}
|
||||
}
|
||||
+10
-1
@@ -208,6 +208,10 @@
|
||||
"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",
|
||||
"login_name": "Nom de connexion",
|
||||
"maxEmailSizeBytes": "Taille max. des e-mails",
|
||||
"maxEmailSizeBytesDescription": "Les e-mails plus grands seront ignorés. Laisser vide pour utiliser la valeur par défaut (100 MB).",
|
||||
"maxEmailSizeBytesPlaceholder": "Par défaut : 100 MB",
|
||||
"maxEmailSizeBytesUnlimited": "Par défaut : 100 MB",
|
||||
"minutes": "minutes",
|
||||
"months": "Mois",
|
||||
"mustBeAtLeast1": "Doit être au moins 1",
|
||||
@@ -532,6 +536,8 @@
|
||||
"accountDeleteFailed": "Échec de la suppression du compte",
|
||||
"accountDeleted": "Compte Supprimé",
|
||||
"accountDeletedDesc": "Votre compte a été supprimé avec succès.",
|
||||
"accountDeletionStarted": "Suppression du compte lancée",
|
||||
"accountDeletionStartedDesc": "Compte en cours de suppression en arrière-plan, disparaîtra après nettoyage.",
|
||||
"allResourcesErased": "Toutes les ressources associées seront effacées définitivement.",
|
||||
"cannotBeUndone": "Cette action ne peut pas être annulée !",
|
||||
"confirmDelete": "Confirmer la Suppression",
|
||||
@@ -1681,6 +1687,9 @@
|
||||
"invalidCronExpression": "Invalid cron expression. Must be 6 fields: second minute hour day-of-month month day-of-week (e.g. '0 0 0 * * *')",
|
||||
"invalidEmail": "Adresse e-mail non valide",
|
||||
"invalidUrl": "URL non valide",
|
||||
"maxEmailSizeMustBeNumber": "La taille maximale des e-mails doit être un nombre.",
|
||||
"maxEmailSizeTooLarge": "La taille maximale des e-mails ne doit pas dépasser 100 MB.",
|
||||
"maxEmailSizeTooSmall": "La taille maximale des e-mails doit être d'au moins 1 MB.",
|
||||
"passwordMinLength": "Le mot de passe doit contenir au moins {{min}} caractères",
|
||||
"passwordRequired": "Le mot de passe est obligatoire lorsque la méthode d'authentification est Mot de passe",
|
||||
"pleaseEnterPassword": "Veuillez entrer votre mot de passe",
|
||||
@@ -1690,4 +1699,4 @@
|
||||
"singleRequestBatchSizeTooLarge": "La taille du lot doit être au plus 200",
|
||||
"singleRequestBatchSizeTooSmall": "La taille du lot doit être au moins 10"
|
||||
}
|
||||
}
|
||||
}
|
||||
+10
-1
@@ -208,6 +208,10 @@
|
||||
"leaveEmptyToKeepExisting": "Lascia vuoto per mantenere la password esistente, o inseriscine una nuova per aggiornarla.",
|
||||
"leaveEmptyToKeepPassword": "Lascia vuoto per mantenere la password attuale",
|
||||
"login_name": "Nome di accesso",
|
||||
"maxEmailSizeBytes": "Dimensione massima email",
|
||||
"maxEmailSizeBytesDescription": "Le email più grandi saranno ignorate. Lascia vuoto per utilizzare il valore predefinito (100 MB).",
|
||||
"maxEmailSizeBytesPlaceholder": "Predefinito: 100 MB",
|
||||
"maxEmailSizeBytesUnlimited": "Predefinito: 100 MB",
|
||||
"minutes": "minuti",
|
||||
"months": "Mesi",
|
||||
"mustBeAtLeast1": "Deve essere almeno 1",
|
||||
@@ -532,6 +536,8 @@
|
||||
"accountDeleteFailed": "Eliminazione account fallita",
|
||||
"accountDeleted": "Account Eliminato",
|
||||
"accountDeletedDesc": "Il tuo account è stato eliminato con successo.",
|
||||
"accountDeletionStarted": "Eliminazione account avviata",
|
||||
"accountDeletionStartedDesc": "L'account è in fase di eliminazione in background e scomparirà dopo la pulizia.",
|
||||
"allResourcesErased": "Tutte le risorse correlate verranno cancellate permanentemente.",
|
||||
"cannotBeUndone": "Questa azione non può essere annullata!",
|
||||
"confirmDelete": "Conferma Eliminazione",
|
||||
@@ -1681,6 +1687,9 @@
|
||||
"invalidCronExpression": "Invalid cron expression. Must be 6 fields: second minute hour day-of-month month day-of-week (e.g. '0 0 0 * * *')",
|
||||
"invalidEmail": "Indirizzo email non valido",
|
||||
"invalidUrl": "URL non valido",
|
||||
"maxEmailSizeMustBeNumber": "La dimensione massima dell'email deve essere un numero.",
|
||||
"maxEmailSizeTooLarge": "La dimensione massima dell'email non deve superare i 100 MB.",
|
||||
"maxEmailSizeTooSmall": "La dimensione massima dell'email deve essere di almeno 1 MB.",
|
||||
"passwordMinLength": "La password deve contenere almeno {{min}} caratteri",
|
||||
"passwordRequired": "La password è obbligatoria quando il metodo di autenticazione è Password",
|
||||
"pleaseEnterPassword": "Inserisci la tua password",
|
||||
@@ -1690,4 +1699,4 @@
|
||||
"singleRequestBatchSizeTooLarge": "La dimensione del batch deve essere al massimo 200",
|
||||
"singleRequestBatchSizeTooSmall": "La dimensione del batch deve essere almeno 10"
|
||||
}
|
||||
}
|
||||
}
|
||||
+10
-1
@@ -208,6 +208,10 @@
|
||||
"leaveEmptyToKeepExisting": "既存のパスワードを保持する場合は空欄にしてください。更新する場合は新しいパスワードを入力してください。",
|
||||
"leaveEmptyToKeepPassword": "現在のパスワードを保持する場合は空欄にしてください",
|
||||
"login_name": "ログイン名",
|
||||
"maxEmailSizeBytes": "最大メールサイズ",
|
||||
"maxEmailSizeBytesDescription": "これより大きいメールはスキップされます。空欄にするとデフォルト(100 MB)が使用されます。",
|
||||
"maxEmailSizeBytesPlaceholder": "デフォルト:100 MB",
|
||||
"maxEmailSizeBytesUnlimited": "デフォルト:100 MB",
|
||||
"minutes": "分",
|
||||
"months": "月",
|
||||
"mustBeAtLeast1": "1以上である必要があります",
|
||||
@@ -532,6 +536,8 @@
|
||||
"accountDeleteFailed": "アカウントの削除に失敗しました",
|
||||
"accountDeleted": "アカウントが削除されました",
|
||||
"accountDeletedDesc": "アカウントが正常に削除されました。",
|
||||
"accountDeletionStarted": "アカウントの削除を開始しました",
|
||||
"accountDeletionStartedDesc": "バックグラウンドで削除中です。完了するとリストから消えます。",
|
||||
"allResourcesErased": "関連するすべてのリソースは完全に消去されます。",
|
||||
"cannotBeUndone": "この操作は元に戻せません!",
|
||||
"confirmDelete": "削除の確認",
|
||||
@@ -1681,6 +1687,9 @@
|
||||
"invalidCronExpression": "Invalid cron expression. Must be 6 fields: second minute hour day-of-month month day-of-week (e.g. '0 0 0 * * *')",
|
||||
"invalidEmail": "無効なメールアドレスです",
|
||||
"invalidUrl": "無効なURLです",
|
||||
"maxEmailSizeMustBeNumber": "最大メールサイズは数値で入力してください。",
|
||||
"maxEmailSizeTooLarge": "最大メールサイズは 100 MB 以下にしてください。",
|
||||
"maxEmailSizeTooSmall": "最大メールサイズは 1 MB 以上にしてください。",
|
||||
"passwordMinLength": "パスワードは{{min}}文字以上である必要があります",
|
||||
"passwordRequired": "認証方式がパスワードの場合、パスワードは必須です",
|
||||
"pleaseEnterPassword": "パスワードを入力してください",
|
||||
@@ -1690,4 +1699,4 @@
|
||||
"singleRequestBatchSizeTooLarge": "バッチサイズは最大でも200でなければなりません",
|
||||
"singleRequestBatchSizeTooSmall": "バッチサイズは最低でも10でなければなりません"
|
||||
}
|
||||
}
|
||||
}
|
||||
+10
-1
@@ -208,6 +208,10 @@
|
||||
"leaveEmptyToKeepExisting": "기존 비밀번호를 유지하려면 비워 두십시오. 업데이트할 경우에만 새 비밀번호를 입력하십시오.",
|
||||
"leaveEmptyToKeepPassword": "현재 비밀번호를 유지하려면 비워 두십시오",
|
||||
"login_name": "로그인 이름",
|
||||
"maxEmailSizeBytes": "최대 이메일 크기",
|
||||
"maxEmailSizeBytesDescription": "이보다 큰 이메일은 건너뜁니다. 기본값(100 MB)을 사용하려면 비워두세요.",
|
||||
"maxEmailSizeBytesPlaceholder": "기본값: 100 MB",
|
||||
"maxEmailSizeBytesUnlimited": "기본값: 100 MB",
|
||||
"minutes": "분",
|
||||
"months": "개월",
|
||||
"mustBeAtLeast1": "최소 1 이상이어야 합니다",
|
||||
@@ -532,6 +536,8 @@
|
||||
"accountDeleteFailed": "계정 삭제 실패",
|
||||
"accountDeleted": "계정 삭제됨",
|
||||
"accountDeletedDesc": "계정이 성공적으로 삭제되었습니다.",
|
||||
"accountDeletionStarted": "계정 삭제 시작됨",
|
||||
"accountDeletionStartedDesc": "백그라운드에서 삭제 중이며, 정리가 끝나면 목록에서 사라집니다.",
|
||||
"allResourcesErased": "모든 관련 리소스가 영구적으로 지워집니다.",
|
||||
"cannotBeUndone": "이 작업은 되돌릴 수 없습니다!",
|
||||
"confirmDelete": "삭제 확인",
|
||||
@@ -1681,6 +1687,9 @@
|
||||
"invalidCronExpression": "Invalid cron expression. Must be 6 fields: second minute hour day-of-month month day-of-week (e.g. '0 0 0 * * *')",
|
||||
"invalidEmail": "유효하지 않은 이메일 주소",
|
||||
"invalidUrl": "유효하지 않은 URL",
|
||||
"maxEmailSizeMustBeNumber": "최대 이메일 크기는 숫자여야 합니다.",
|
||||
"maxEmailSizeTooLarge": "최대 이메일 크기는 100 MB를 초과할 수 없습니다.",
|
||||
"maxEmailSizeTooSmall": "최대 이메일 크기는 최소 1 MB여야 합니다.",
|
||||
"passwordMinLength": "비밀번호는 {{min}}자 이상이어야 합니다",
|
||||
"passwordRequired": "인증 방법이 비밀번호인 경우 비밀번호는 필수입니다",
|
||||
"pleaseEnterPassword": "비밀번호를 입력하십시오",
|
||||
@@ -1690,4 +1699,4 @@
|
||||
"singleRequestBatchSizeTooLarge": "배치 크기는 최대 200이어야 합니다",
|
||||
"singleRequestBatchSizeTooSmall": "배치 크기는 최소 10이어야 합니다"
|
||||
}
|
||||
}
|
||||
}
|
||||
+10
-1
@@ -208,6 +208,10 @@
|
||||
"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",
|
||||
"login_name": "Inlognaam",
|
||||
"maxEmailSizeBytes": "Max. e-mailgrootte",
|
||||
"maxEmailSizeBytesDescription": "E-mails groter dan dit worden overgeslagen. Laat leeg om de standaard (100 MB) te gebruiken.",
|
||||
"maxEmailSizeBytesPlaceholder": "Standaard: 100 MB",
|
||||
"maxEmailSizeBytesUnlimited": "Standaard: 100 MB",
|
||||
"minutes": "minuten",
|
||||
"months": "Maanden",
|
||||
"mustBeAtLeast1": "Moet ten minste 1 zijn",
|
||||
@@ -532,6 +536,8 @@
|
||||
"accountDeleteFailed": "Account verwijderen Mislukt",
|
||||
"accountDeleted": "Account Verwijderd",
|
||||
"accountDeletedDesc": "Uw account is succesvol verwijderd.",
|
||||
"accountDeletionStarted": "Verwijdering account gestart",
|
||||
"accountDeletionStartedDesc": "Account wordt op de achtergrond verwijderd en verdwijnt na opschonen.",
|
||||
"allResourcesErased": "Alle gerelateerde bronnen worden permanent gewist.",
|
||||
"cannotBeUndone": "Deze actie kan niet ongedaan worden gemaakt!",
|
||||
"confirmDelete": "Verwijdering Bevestigen",
|
||||
@@ -1681,6 +1687,9 @@
|
||||
"invalidCronExpression": "Invalid cron expression. Must be 6 fields: second minute hour day-of-month month day-of-week (e.g. '0 0 0 * * *')",
|
||||
"invalidEmail": "Ongeldig e-mailadres",
|
||||
"invalidUrl": "Ongeldige URL",
|
||||
"maxEmailSizeMustBeNumber": "Maximale e-mailgrootte moet un nummer zijn.",
|
||||
"maxEmailSizeTooLarge": "Maximale e-mailgrootte mag niet groter zijn dan 100 MB.",
|
||||
"maxEmailSizeTooSmall": "Maximale e-mailgrootte moet minstens 1 MB zijn.",
|
||||
"passwordMinLength": "Wachtwoord moet ten minste {{min}} tekens lang zijn",
|
||||
"passwordRequired": "Wachtwoord is vereist wanneer de authenticatiemethode Wachtwoord is",
|
||||
"pleaseEnterPassword": "Voer uw wachtwoord in",
|
||||
@@ -1690,4 +1699,4 @@
|
||||
"singleRequestBatchSizeTooLarge": "Batch‑grootte moet hoogstens 200 zijn",
|
||||
"singleRequestBatchSizeTooSmall": "Batch‑grootte moet ten minste 10 zijn"
|
||||
}
|
||||
}
|
||||
}
|
||||
+10
-1
@@ -208,6 +208,10 @@
|
||||
"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",
|
||||
"login_name": "Påloggingsnavn",
|
||||
"maxEmailSizeBytes": "Maks. e-poststørrelse",
|
||||
"maxEmailSizeBytesDescription": "E-poster større enn dette vil bli hoppet over. La stå tom for å bruke standarden (100 MB).",
|
||||
"maxEmailSizeBytesPlaceholder": "Standard: 100 MB",
|
||||
"maxEmailSizeBytesUnlimited": "Standard: 100 MB",
|
||||
"minutes": "minutter",
|
||||
"months": "Måneder",
|
||||
"mustBeAtLeast1": "Må være minst 1",
|
||||
@@ -532,6 +536,8 @@
|
||||
"accountDeleteFailed": "Sletting av konto mislyktes",
|
||||
"accountDeleted": "Konto slettet",
|
||||
"accountDeletedDesc": "Kontoen din har blitt slettet.",
|
||||
"accountDeletionStarted": "Kontosletting startet",
|
||||
"accountDeletionStartedDesc": "Kontoen slettes i bakgrunnen og forsvinner når opprydningen er ferdig.",
|
||||
"allResourcesErased": "Alle relaterte ressurser vil bli permanent slettet.",
|
||||
"cannotBeUndone": "Denne handlingen kan ikke angres!",
|
||||
"confirmDelete": "Bekreft sletting",
|
||||
@@ -1681,6 +1687,9 @@
|
||||
"invalidCronExpression": "Invalid cron expression. Must be 6 fields: second minute hour day-of-month month day-of-week (e.g. '0 0 0 * * *')",
|
||||
"invalidEmail": "Ugyldig e-postadresse",
|
||||
"invalidUrl": "Ugyldig URL",
|
||||
"maxEmailSizeMustBeNumber": "Maks. e-poststørrelse må være et tall.",
|
||||
"maxEmailSizeTooLarge": "Maks. e-poststørrelse må ikke overstige 100 MB.",
|
||||
"maxEmailSizeTooSmall": "Maks. e-poststørrelse må være minst 1 MB.",
|
||||
"passwordMinLength": "Passordet må være minst {{min}} tegn langt",
|
||||
"passwordRequired": "Passord er påkrevd når autentiseringsmetoden er Passord",
|
||||
"pleaseEnterPassword": "Vennligst skriv inn passordet ditt",
|
||||
@@ -1690,4 +1699,4 @@
|
||||
"singleRequestBatchSizeTooLarge": "Batchstørrelse må være maksimalt 200",
|
||||
"singleRequestBatchSizeTooSmall": "Batchstørrelse må være minst 10"
|
||||
}
|
||||
}
|
||||
}
|
||||
+10
-1
@@ -208,6 +208,10 @@
|
||||
"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",
|
||||
"login_name": "Login",
|
||||
"maxEmailSizeBytes": "Maks. rozmiar e-maila",
|
||||
"maxEmailSizeBytesDescription": "Większe wiadomości zostaną pominięte. Pozostaw puste, aby użyć domyślnego limitu (100 MB).",
|
||||
"maxEmailSizeBytesPlaceholder": "Domyślnie: 100 MB",
|
||||
"maxEmailSizeBytesUnlimited": "Domyślnie: 100 MB",
|
||||
"minutes": "minut",
|
||||
"months": "Miesiące",
|
||||
"mustBeAtLeast1": "Nie mniej jak 1",
|
||||
@@ -532,6 +536,8 @@
|
||||
"accountDeleteFailed": "Bład podczas usuwania konta",
|
||||
"accountDeleted": "Konto usunięte",
|
||||
"accountDeletedDesc": "Konto zostało usunięte.",
|
||||
"accountDeletionStarted": "Rozpoczęto usuwanie konta",
|
||||
"accountDeletionStartedDesc": "Konto jest usuwane w tle i zniknie po zakończeniu czyszczenia.",
|
||||
"allResourcesErased": "Wszystkie powiązane zasoby zostaną trwale usunięte.",
|
||||
"cannotBeUndone": "Tej czynności nie można cofnąć!",
|
||||
"confirmDelete": "Potwierdź usunięcie",
|
||||
@@ -1681,6 +1687,9 @@
|
||||
"invalidCronExpression": "Invalid cron expression. Must be 6 fields: second minute hour day-of-month month day-of-week (e.g. '0 0 0 * * *')",
|
||||
"invalidEmail": "Niewłaściwy adres email",
|
||||
"invalidUrl": "Niewłaściwy URL",
|
||||
"maxEmailSizeMustBeNumber": "Maksymalny rozmiar e-maila musi być liczbą.",
|
||||
"maxEmailSizeTooLarge": "Maksymalny rozmiar e-maila nie może przekraczać 100 MB.",
|
||||
"maxEmailSizeTooSmall": "Maksymalny rozmiar e-maila musi wynosić co najmniej 1 MB.",
|
||||
"passwordMinLength": "Hasło musi posiadać conajmniej {{min}} znaków",
|
||||
"passwordRequired": "Hasło jest wymagane, gdy metodą uwierzytelniania jest hasło",
|
||||
"pleaseEnterPassword": "Proszę podać hasło",
|
||||
@@ -1690,4 +1699,4 @@
|
||||
"singleRequestBatchSizeTooLarge": "Rozmiar partii może wynosić maksymalnie 200",
|
||||
"singleRequestBatchSizeTooSmall": "Rozmiar partii musi wynosić co najmniej 10"
|
||||
}
|
||||
}
|
||||
}
|
||||
+10
-1
@@ -208,6 +208,10 @@
|
||||
"leaveEmptyToKeepExisting": "Deixe vazio para manter a senha existente. Insira a nova senha apenas se estiver atualizando.",
|
||||
"leaveEmptyToKeepPassword": "Deixe vazio para manter a senha atual",
|
||||
"login_name": "Nome de login",
|
||||
"maxEmailSizeBytes": "Tamanho máx. do email",
|
||||
"maxEmailSizeBytesDescription": "Emails maiores do que isso serão ignorados. Deixe vazio para usar o padrão (100 MB).",
|
||||
"maxEmailSizeBytesPlaceholder": "Padrão: 100 MB",
|
||||
"maxEmailSizeBytesUnlimited": "Padrão: 100 MB",
|
||||
"minutes": "minutos",
|
||||
"months": "Meses",
|
||||
"mustBeAtLeast1": "Deve ser pelo menos 1",
|
||||
@@ -532,6 +536,8 @@
|
||||
"accountDeleteFailed": "Falha ao Excluir Conta",
|
||||
"accountDeleted": "Conta Excluída",
|
||||
"accountDeletedDesc": "A conta foi excluída com sucesso.",
|
||||
"accountDeletionStarted": "Exclusão da conta iniciada",
|
||||
"accountDeletionStartedDesc": "A conta está sendo excluída em segundo plano e desaparecerá após a limpeza.",
|
||||
"allResourcesErased": "Todos os recursos relacionados serão permanentemente apagados.",
|
||||
"cannotBeUndone": "Esta ação não pode ser desfeita!",
|
||||
"confirmDelete": "Confirmar Exclusão",
|
||||
@@ -1681,6 +1687,9 @@
|
||||
"invalidCronExpression": "Invalid cron expression. Must be 6 fields: second minute hour day-of-month month day-of-week (e.g. '0 0 0 * * *')",
|
||||
"invalidEmail": "Endereço de email inválido",
|
||||
"invalidUrl": "URL inválido",
|
||||
"maxEmailSizeMustBeNumber": "O tamanho máximo do email deve ser um número.",
|
||||
"maxEmailSizeTooLarge": "O tamanho máximo do email não deve exceder 100 MB.",
|
||||
"maxEmailSizeTooSmall": "O tamanho máximo do email deve ser de pelo menos 1 MB.",
|
||||
"passwordMinLength": "A senha deve ter pelo menos {{min}} caracteres",
|
||||
"passwordRequired": "A senha é obrigatória se o método de autenticação for Senha",
|
||||
"pleaseEnterPassword": "Por favor, insira a senha",
|
||||
@@ -1690,4 +1699,4 @@
|
||||
"singleRequestBatchSizeTooLarge": "O tamanho do lote deve ser no máximo 200",
|
||||
"singleRequestBatchSizeTooSmall": "O tamanho do lote deve ser pelo menos 10"
|
||||
}
|
||||
}
|
||||
}
|
||||
+10
-1
@@ -208,6 +208,10 @@
|
||||
"leaveEmptyToKeepExisting": "Оставьте пустым, чтобы сохранить существующий пароль, или введите новый для обновления.",
|
||||
"leaveEmptyToKeepPassword": "Оставьте пустым, чтобы сохранить текущий пароль",
|
||||
"login_name": "Имя для входа",
|
||||
"maxEmailSizeBytes": "Макс. размер письма",
|
||||
"maxEmailSizeBytesDescription": "Письма больше этого размера будут пропущены. Оставьте пустым для использования значения по умолчанию (100 МБ).",
|
||||
"maxEmailSizeBytesPlaceholder": "По умолчанию: 100 МБ",
|
||||
"maxEmailSizeBytesUnlimited": "По умолчанию: 100 МБ",
|
||||
"minutes": "минут",
|
||||
"months": "Месяцы",
|
||||
"mustBeAtLeast1": "Должно быть не менее 1",
|
||||
@@ -532,6 +536,8 @@
|
||||
"accountDeleteFailed": "Ошибка удаления аккаунта",
|
||||
"accountDeleted": "Аккаунт удален",
|
||||
"accountDeletedDesc": "Ваш аккаунт был успешно удален.",
|
||||
"accountDeletionStarted": "Удаление аккаунта запущено",
|
||||
"accountDeletionStartedDesc": "Аккаунт удаляется в фоновом режиме и исчезнет после очистки.",
|
||||
"allResourcesErased": "Все связанные ресурсы будут безвозвратно стерты.",
|
||||
"cannotBeUndone": "Это действие нельзя отменить!",
|
||||
"confirmDelete": "Подтвердить удаление",
|
||||
@@ -1681,6 +1687,9 @@
|
||||
"invalidCronExpression": "Invalid cron expression. Must be 6 fields: second minute hour day-of-month month day-of-week (e.g. '0 0 0 * * *')",
|
||||
"invalidEmail": "Неверный адрес электронной почты",
|
||||
"invalidUrl": "Неверный URL",
|
||||
"maxEmailSizeMustBeNumber": "Максимальный размер письма должен быть числом.",
|
||||
"maxEmailSizeTooLarge": "Максимальный размер письма не должен превышать 100 МБ.",
|
||||
"maxEmailSizeTooSmall": "Максимальный размер письма должен быть не менее 1 МБ.",
|
||||
"passwordMinLength": "Пароль должен быть не менее {{min}} символов",
|
||||
"passwordRequired": "Пароль обязателен, когда метод авторизации - Пароль",
|
||||
"pleaseEnterPassword": "Пожалуйста, введите ваш пароль",
|
||||
@@ -1690,4 +1699,4 @@
|
||||
"singleRequestBatchSizeTooLarge": "Размер пакета должен быть не более 200",
|
||||
"singleRequestBatchSizeTooSmall": "Размер пакета должен быть не менее 10"
|
||||
}
|
||||
}
|
||||
}
|
||||
+10
-1
@@ -208,6 +208,10 @@
|
||||
"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",
|
||||
"login_name": "Inloggningsnamn",
|
||||
"maxEmailSizeBytes": "Max e-poststorlek",
|
||||
"maxEmailSizeBytesDescription": "E-post större än detta kommer att hoppas över. Lämna tomt för att använda standard (100 MB).",
|
||||
"maxEmailSizeBytesPlaceholder": "Standard: 100 MB",
|
||||
"maxEmailSizeBytesUnlimited": "Standard: 100 MB",
|
||||
"minutes": "minuter",
|
||||
"months": "Månader",
|
||||
"mustBeAtLeast1": "Måste vara minst 1",
|
||||
@@ -532,6 +536,8 @@
|
||||
"accountDeleteFailed": "Borttagning av konto misslyckades",
|
||||
"accountDeleted": "Konto raderat",
|
||||
"accountDeletedDesc": "Ditt konto har tagits bort.",
|
||||
"accountDeletionStarted": "Kontoradering har startat",
|
||||
"accountDeletionStartedDesc": "Kontot raderas i bakgrunden och försvinner när rensningen är klar.",
|
||||
"allResourcesErased": "Alla relaterade resurser kommer att raderas permanent.",
|
||||
"cannotBeUndone": "Denna åtgärd kan inte ångras!",
|
||||
"confirmDelete": "Bekräfta borttagning",
|
||||
@@ -1681,6 +1687,9 @@
|
||||
"invalidCronExpression": "Invalid cron expression. Must be 6 fields: second minute hour day-of-month month day-of-week (e.g. '0 0 0 * * *')",
|
||||
"invalidEmail": "Ogiltig e-postadress",
|
||||
"invalidUrl": "Ogiltig URL",
|
||||
"maxEmailSizeMustBeNumber": "Max e-poststorlek måste vara ett nummer.",
|
||||
"maxEmailSizeTooLarge": "Max e-poststorlek får inte överstiga 100 MB.",
|
||||
"maxEmailSizeTooSmall": "Max e-poststorlek måste vara minst 1 MB.",
|
||||
"passwordMinLength": "Lösenordet måste vara minst {{min}} tecken långt",
|
||||
"passwordRequired": "Lösenord krävs när autentiseringsmetoden är Lösenord",
|
||||
"pleaseEnterPassword": "Vänligen ange ditt lösenord",
|
||||
@@ -1690,4 +1699,4 @@
|
||||
"singleRequestBatchSizeTooLarge": "Batchstorlek måste vara högst 200",
|
||||
"singleRequestBatchSizeTooSmall": "Batchstorlek måste vara minst 10"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -208,6 +208,10 @@
|
||||
"leaveEmptyToKeepExisting": "保留現有密碼請留空。若要更新,請輸入新密碼。",
|
||||
"leaveEmptyToKeepPassword": "保留現有密碼請留空",
|
||||
"login_name": "登入名稱",
|
||||
"maxEmailSizeBytes": "最大郵件大小",
|
||||
"maxEmailSizeBytesDescription": "超出此大小的郵件將被跳過。留空則使用預設值(100 MB)。",
|
||||
"maxEmailSizeBytesPlaceholder": "預設:100 MB",
|
||||
"maxEmailSizeBytesUnlimited": "預設:100 MB",
|
||||
"minutes": "分鐘",
|
||||
"months": "月",
|
||||
"mustBeAtLeast1": "必須大於或等於 1",
|
||||
@@ -532,6 +536,8 @@
|
||||
"accountDeleteFailed": "帳號刪除失敗",
|
||||
"accountDeleted": "帳號已刪除",
|
||||
"accountDeletedDesc": "帳號已成功刪除。",
|
||||
"accountDeletionStarted": "帳戶刪除已開始",
|
||||
"accountDeletionStartedDesc": "帳戶正在背景刪除,清理完成後將從列表中消失。",
|
||||
"allResourcesErased": "所有相關資源將被永久清除。",
|
||||
"cannotBeUndone": "此操作無法復原!",
|
||||
"confirmDelete": "確認刪除",
|
||||
@@ -1681,6 +1687,9 @@
|
||||
"invalidCronExpression": "Invalid cron expression. Must be 6 fields: second minute hour day-of-month month day-of-week (e.g. '0 0 0 * * *')",
|
||||
"invalidEmail": "無效的電子郵件地址",
|
||||
"invalidUrl": "無效的網址",
|
||||
"maxEmailSizeMustBeNumber": "最大郵件大小必須是數字。",
|
||||
"maxEmailSizeTooLarge": "最大郵件大小不能超過 100 MB。",
|
||||
"maxEmailSizeTooSmall": "最大郵件大小不能小於 1 MB。",
|
||||
"passwordMinLength": "密碼長度必須至少 {{min}} 個字元",
|
||||
"passwordRequired": "如果驗證方法是密碼,則密碼為必填項",
|
||||
"pleaseEnterPassword": "請輸入密碼",
|
||||
@@ -1690,4 +1699,4 @@
|
||||
"singleRequestBatchSizeTooLarge": "批次大小必須最多為200",
|
||||
"singleRequestBatchSizeTooSmall": "批次大小必須至少為10"
|
||||
}
|
||||
}
|
||||
}
|
||||
+10
-1
@@ -208,6 +208,10 @@
|
||||
"leaveEmptyToKeepExisting": "留空以保持现有密码,或输入新密码进行更新。",
|
||||
"leaveEmptyToKeepPassword": "留空以保持当前密码",
|
||||
"login_name": "登录名",
|
||||
"maxEmailSizeBytes": "最大邮件大小",
|
||||
"maxEmailSizeBytesDescription": "超出此大小的邮件将被跳过。留空则使用默认值(100 MB)。",
|
||||
"maxEmailSizeBytesPlaceholder": "默认:100 MB",
|
||||
"maxEmailSizeBytesUnlimited": "默认:100 MB",
|
||||
"minutes": "分钟",
|
||||
"months": "月",
|
||||
"mustBeAtLeast1": "必须至少为 1",
|
||||
@@ -532,6 +536,8 @@
|
||||
"accountDeleteFailed": "账户删除失败",
|
||||
"accountDeleted": "账户已删除",
|
||||
"accountDeletedDesc": "您的账户已成功删除。",
|
||||
"accountDeletionStarted": "账户删除已开始",
|
||||
"accountDeletionStartedDesc": "账户正在后台删除,清理完成后将从列表中消失。",
|
||||
"allResourcesErased": "所有相关资源将被永久删除。",
|
||||
"cannotBeUndone": "此操作无法撤销!",
|
||||
"confirmDelete": "确认删除",
|
||||
@@ -1681,6 +1687,9 @@
|
||||
"invalidCronExpression": "Invalid cron expression. Must be 6 fields: second minute hour day-of-month month day-of-week (e.g. '0 0 0 * * *')",
|
||||
"invalidEmail": "无效的电子邮件地址",
|
||||
"invalidUrl": "无效的 URL",
|
||||
"maxEmailSizeMustBeNumber": "最大邮件大小必须是数字。",
|
||||
"maxEmailSizeTooLarge": "最大邮件大小不能超过 100 MB。",
|
||||
"maxEmailSizeTooSmall": "最大邮件大小不能小于 1 MB。",
|
||||
"passwordMinLength": "密码长度至少为 {{min}} 个字符",
|
||||
"passwordRequired": "当认证方法为密码时,密码为必填项",
|
||||
"pleaseEnterPassword": "请输入您的密码",
|
||||
@@ -1690,4 +1699,4 @@
|
||||
"singleRequestBatchSizeTooLarge": "批大小必须最多为200",
|
||||
"singleRequestBatchSizeTooSmall": "批大小必须至少为10"
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user