mirror of
https://github.com/rustmailer/bichon.git
synced 2026-08-31 01:52:30 +00:00
Compare commits
29 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
de871225ae | ||
|
|
388773bd2e | ||
|
|
a0d69f43b6 | ||
|
|
c4a1e36c61 | ||
|
|
02b8741725 | ||
|
|
d9097f85cf | ||
|
|
704678234b | ||
|
|
0d800a48bb | ||
|
|
eaba876de5 | ||
|
|
f357d45235 | ||
|
|
46eeab8100 | ||
|
|
fa98f161c6 | ||
|
|
8eac80e15c | ||
|
|
e72a53a4d0 | ||
|
|
0899aafbb3 | ||
|
|
1b2952a6b0 | ||
|
|
ae7a681751 | ||
|
|
2d61f5b555 | ||
|
|
c067655171 | ||
|
|
350ec8da1e | ||
|
|
9a5816a458 | ||
|
|
9e0755c9ed | ||
|
|
d27b0f274f | ||
|
|
6e1c75bba8 | ||
|
|
17750e9b80 | ||
|
|
4809f298e9 | ||
|
|
a07e8b3a78 | ||
|
|
eb2e4b5393 | ||
|
|
adb94263c0 |
10
Cargo.lock
generated
10
Cargo.lock
generated
@@ -305,7 +305,7 @@ checksum = "b25655df2c3cdd83c5e5b293b88acd880332b2ddadd7c30ac43144fdc0033da9"
|
||||
|
||||
[[package]]
|
||||
name = "bichon-admin"
|
||||
version = "2.0.0"
|
||||
version = "2.0.2"
|
||||
dependencies = [
|
||||
"bichon-blob",
|
||||
"bichon-core",
|
||||
@@ -352,7 +352,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "bichon-cli"
|
||||
version = "2.0.0"
|
||||
version = "2.0.2"
|
||||
dependencies = [
|
||||
"base64 0.23.0",
|
||||
"bichon-core",
|
||||
@@ -374,7 +374,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "bichon-core"
|
||||
version = "2.0.0"
|
||||
version = "2.0.2"
|
||||
dependencies = [
|
||||
"async-imap",
|
||||
"base64 0.23.0",
|
||||
@@ -450,7 +450,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "bichon-server"
|
||||
version = "2.0.0"
|
||||
version = "2.0.2"
|
||||
dependencies = [
|
||||
"bichon-core",
|
||||
"bichon-smtp",
|
||||
@@ -475,7 +475,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "bichon-smtp"
|
||||
version = "2.0.0"
|
||||
version = "2.0.2"
|
||||
dependencies = [
|
||||
"base64 0.23.0",
|
||||
"bichon-core",
|
||||
|
||||
@@ -13,7 +13,7 @@ members = [
|
||||
resolver = "2"
|
||||
|
||||
[workspace.package]
|
||||
version = "2.0.0"
|
||||
version = "2.0.2"
|
||||
edition = "2021"
|
||||
|
||||
[workspace.dependencies]
|
||||
|
||||
@@ -11,7 +11,7 @@ use bichon_core::{
|
||||
since::{DateSince, RelativeDate},
|
||||
},
|
||||
autoconfig::entity::MailServerConfig,
|
||||
cache::imap::mailbox::Attribute,
|
||||
archive::imap::mailbox::Attribute,
|
||||
database::batch_insert_impl,
|
||||
error::{code::ErrorCode, BichonError, BichonResult},
|
||||
raise_error,
|
||||
@@ -653,7 +653,7 @@ pub struct MailBox {
|
||||
pub uid_validity: Option<u32>,
|
||||
}
|
||||
|
||||
impl From<MailBox> for bichon_core::cache::imap::mailbox::MailBox {
|
||||
impl From<MailBox> for bichon_core::archive::imap::mailbox::MailBox {
|
||||
fn from(value: MailBox) -> Self {
|
||||
Self {
|
||||
id: value.id,
|
||||
@@ -898,7 +898,7 @@ pub fn migrate_metadata(root_path: &PathBuf) -> Result<(), Box<dyn std::error::E
|
||||
migrate_collection!(
|
||||
"Mailboxes",
|
||||
MailBox,
|
||||
bichon_core::cache::imap::mailbox::MailBox,
|
||||
bichon_core::archive::imap::mailbox::MailBox,
|
||||
&envelope_db
|
||||
);
|
||||
|
||||
|
||||
@@ -35,7 +35,6 @@ fn migrate_keyspace(
|
||||
label: &str,
|
||||
batch_size: usize,
|
||||
) -> BichonResult<u64> {
|
||||
|
||||
let ks = db
|
||||
.keyspace(ks_name, || {
|
||||
panic!("{ks_name} keyspace not found in fjall database")
|
||||
@@ -49,8 +48,7 @@ fn migrate_keyspace(
|
||||
|
||||
let pb = ProgressBar::new_spinner();
|
||||
pb.set_style(
|
||||
ProgressStyle::with_template("{spinner:.cyan} {msg} [{elapsed_precise}]")
|
||||
.unwrap(),
|
||||
ProgressStyle::with_template("{spinner:.cyan} {msg} [{elapsed_precise}]").unwrap(),
|
||||
);
|
||||
pb.set_message(format!("Scanning {label} blobs..."));
|
||||
|
||||
@@ -86,9 +84,9 @@ fn migrate_keyspace(
|
||||
batch.push((raw_key, value.to_vec(), Codec::Zstd));
|
||||
|
||||
if batch.len() >= batch_size {
|
||||
engine.put_batch(&batch).map_err(|e| {
|
||||
raise_error!(format!("{e:#?}"), ErrorCode::InternalError)
|
||||
})?;
|
||||
engine
|
||||
.put_batch(&batch)
|
||||
.map_err(|e| raise_error!(format!("{e:#?}"), ErrorCode::InternalError))?;
|
||||
count += batch.len() as u64;
|
||||
pb.set_message(format!("{label}: {} blobs migrated...", count));
|
||||
batch.clear();
|
||||
@@ -96,9 +94,9 @@ fn migrate_keyspace(
|
||||
}
|
||||
|
||||
if !batch.is_empty() {
|
||||
engine.put_batch(&batch).map_err(|e| {
|
||||
raise_error!(format!("{e:#?}"), ErrorCode::InternalError)
|
||||
})?;
|
||||
engine
|
||||
.put_batch(&batch)
|
||||
.map_err(|e| raise_error!(format!("{e:#?}"), ErrorCode::InternalError))?;
|
||||
count += batch.len() as u64;
|
||||
}
|
||||
|
||||
@@ -115,9 +113,11 @@ pub fn handle_migrate_v1(theme: &ColorfulTheme) {
|
||||
);
|
||||
println!(
|
||||
"{}\n",
|
||||
style("This migrates blob storage from the fjall engine to bichon-blob.\n\
|
||||
Tantivy indexes and metadata (memdb) are NOT affected.")
|
||||
.dim()
|
||||
style(
|
||||
"This migrates blob storage from the fjall engine to bichon-blob.\n\
|
||||
Tantivy indexes and metadata (memdb) are NOT affected."
|
||||
)
|
||||
.dim()
|
||||
);
|
||||
|
||||
let root_dir: String = Input::with_theme(theme)
|
||||
@@ -188,7 +188,9 @@ pub fn handle_migrate_v1(theme: &ColorfulTheme) {
|
||||
|
||||
let batch_size: usize = {
|
||||
let input: String = Input::with_theme(theme)
|
||||
.with_prompt("Enter batch size (affects memory usage, higher = faster but uses more RAM)")
|
||||
.with_prompt(
|
||||
"Enter batch size (affects memory usage, higher = faster but uses more RAM)",
|
||||
)
|
||||
.default("1000".to_string())
|
||||
.validate_with(|s: &String| match s.trim().parse::<usize>() {
|
||||
Ok(n) if n > 0 => Ok(()),
|
||||
@@ -248,29 +250,29 @@ pub fn handle_migrate_v1(theme: &ColorfulTheme) {
|
||||
};
|
||||
|
||||
// Migrate attachment blobs
|
||||
let attach_count =
|
||||
match migrate_keyspace(&engine, &db, "attachments", "Attachment", batch_size) {
|
||||
Ok(n) => n,
|
||||
Err(e) => {
|
||||
println!(
|
||||
"{}",
|
||||
style(format!("Attachment migration failed: {e:#?}")).red()
|
||||
);
|
||||
let _ = engine.shutdown();
|
||||
return;
|
||||
}
|
||||
};
|
||||
let attach_count = match migrate_keyspace(&engine, &db, "attachments", "Attachment", batch_size)
|
||||
{
|
||||
Ok(n) => n,
|
||||
Err(e) => {
|
||||
println!(
|
||||
"{}",
|
||||
style(format!("Attachment migration failed: {e:#?}")).red()
|
||||
);
|
||||
let _ = engine.shutdown();
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// Flush and shutdown
|
||||
println!("\n{}", style("Flushing and shutting down blob engine...").dim());
|
||||
println!(
|
||||
"\n{}",
|
||||
style("Flushing and shutting down blob engine...").dim()
|
||||
);
|
||||
if let Err(e) = engine.flush() {
|
||||
println!("{}", style(format!("flush warning: {e:#?}")).yellow());
|
||||
}
|
||||
if let Err(e) = engine.shutdown() {
|
||||
println!(
|
||||
"{}",
|
||||
style(format!("shutdown error: {e:#?}")).red()
|
||||
);
|
||||
println!("{}", style(format!("shutdown error: {e:#?}")).red());
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -286,13 +288,23 @@ pub fn handle_migrate_v1(theme: &ColorfulTheme) {
|
||||
println!(
|
||||
"\n{}",
|
||||
style(format!(
|
||||
"Migration complete!\n Email blobs: {}\n Attachment blobs: {}\n Total: {}\n\n\
|
||||
The old fjall database at '{}' is no longer used.\n\
|
||||
You may delete it to free disk space after verifying everything works.",
|
||||
"✅ Migration complete!\n\
|
||||
\n\
|
||||
📊 {} email blobs, {} attachment blobs migrated\n\
|
||||
\n\
|
||||
📖 **Next steps:**\n\
|
||||
Refer to the official migration guide for:\n\
|
||||
• How to verify the new storage\n\
|
||||
• Cleanup commands for legacy files\n\
|
||||
• Rollback instructions if needed\n\
|
||||
\n\
|
||||
🔗 {}\n\
|
||||
\n\
|
||||
⚠️ **Important:** Old data is preserved until you manually remove it.\n\
|
||||
Do not delete anything until you have verified the new server works correctly.",
|
||||
email_count,
|
||||
attach_count,
|
||||
email_count + attach_count,
|
||||
fjall_path.display()
|
||||
"https://github.com/rustmailer/bichon/wiki/Bichon-v2.x-Migration-Guide"
|
||||
))
|
||||
.green()
|
||||
.bold()
|
||||
|
||||
@@ -27,7 +27,7 @@ use crate::{
|
||||
since::{DateSince, RelativeDate},
|
||||
state::DownloadState,
|
||||
},
|
||||
cache::imap::{mailbox::MailBox, task::SYNC_TASKS},
|
||||
archive::imap::{mailbox::MailBox, task::SYNC_TASKS},
|
||||
common::paginated::DataPage,
|
||||
context::controller::DOWNLOAD_CONTROLLER,
|
||||
database::{
|
||||
|
||||
@@ -22,8 +22,8 @@ use crate::{
|
||||
decode_mailbox_name, raise_error,
|
||||
{
|
||||
account::migration::{AccountModel, AccountType},
|
||||
cache::imap::mailbox::{AttributeEnum, MailBox},
|
||||
cache::imap::mailbox_cache,
|
||||
archive::imap::mailbox::{AttributeEnum, MailBox},
|
||||
archive::imap::mailbox_cache,
|
||||
error::{code::ErrorCode, BichonResult},
|
||||
imap::{executor::ImapExecutor, session::SessionStream},
|
||||
mailbox::list::convert_names_to_mailboxes,
|
||||
@@ -23,7 +23,7 @@ use crate::{
|
||||
migration::AccountModel,
|
||||
state::{DownloadState, DownloadStatus, FolderStatus},
|
||||
},
|
||||
cache::{
|
||||
archive::{
|
||||
imap::{
|
||||
download::rebuild::{rebuild_mailbox_cache, rebuild_mailbox_cache_by_date},
|
||||
find_intersecting_mailboxes, find_missing_mailboxes,
|
||||
@@ -27,7 +27,7 @@ use crate::{
|
||||
migration::AccountModel,
|
||||
state::{DownloadState, GapFillFolderStats, GapFillState},
|
||||
},
|
||||
cache::imap::mailbox::MailBox,
|
||||
archive::imap::mailbox::MailBox,
|
||||
error::BichonResult,
|
||||
imap::executor::{compress_uid_list, ImapExecutor, DEFAULT_BATCH_SIZE},
|
||||
store::tantivy::envelope::EnvelopeSnapshot,
|
||||
@@ -378,7 +378,7 @@ pub async fn gap_fill_mailbox(
|
||||
if let Some(max_uid) = missing_uids.last().copied() {
|
||||
let mut updated = remote_mailbox.clone();
|
||||
updated.highest_uid = Some(max_uid.max(local_mailbox.highest_uid.unwrap_or(0)));
|
||||
crate::cache::imap::mailbox::MailBox::batch_upsert(&[updated])?;
|
||||
crate::archive::imap::mailbox::MailBox::batch_upsert(&[updated])?;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ use crate::{
|
||||
TriggerType,
|
||||
},
|
||||
},
|
||||
cache::imap::{download::flow::FetchDirection, mailbox::MailBox},
|
||||
archive::imap::{download::flow::FetchDirection, mailbox::MailBox},
|
||||
error::BichonResult,
|
||||
imap::executor::ImapExecutor,
|
||||
};
|
||||
@@ -21,7 +21,7 @@ use crate::{
|
||||
migration::AccountModel,
|
||||
state::{DownloadState, DownloadStatus, FolderStatus},
|
||||
},
|
||||
cache::{
|
||||
archive::{
|
||||
imap::{
|
||||
download::flow::{fetch_and_save_by_date, fetch_and_save_full_mailbox, FetchDirection},
|
||||
mailbox::MailBox,
|
||||
@@ -16,7 +16,7 @@
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
use crate::cache::imap::mailbox::MailBox;
|
||||
use crate::archive::imap::mailbox::MailBox;
|
||||
use crate::utc_now;
|
||||
use lru::LruCache;
|
||||
use std::collections::HashMap;
|
||||
@@ -18,7 +18,7 @@
|
||||
|
||||
use crate::account::entity::AuthType;
|
||||
use crate::account::state::{DownloadState, TriggerType};
|
||||
use crate::cache::imap::download::process_imap_download;
|
||||
use crate::archive::imap::download::process_imap_download;
|
||||
use crate::common::periodic::{PeriodicTask, TaskHandle};
|
||||
use crate::error::code::ErrorCode;
|
||||
use crate::oauth2::token::OAuth2AccessToken;
|
||||
@@ -16,7 +16,7 @@
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
use crate::{cache::imap::task::SYNC_TASKS, error::BichonResult};
|
||||
use crate::{archive::imap::task::SYNC_TASKS, error::BichonResult};
|
||||
use std::{sync::LazyLock, time::Duration};
|
||||
use tokio::sync::mpsc;
|
||||
use tracing::{error, info};
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
//
|
||||
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
|
||||
//
|
||||
// This file is part of the Bichon Email Archiving Project
|
||||
@@ -16,32 +15,48 @@
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
use crate::account::migration::AccountModel;
|
||||
use crate::cache::imap::mailbox::MailBox;
|
||||
use crate::common::AddrVec;
|
||||
use crate::envelope::meta::parse_bichon_metadata;
|
||||
use crate::envelope::utils::normalize_subject;
|
||||
use crate::error::code::ErrorCode;
|
||||
use crate::error::BichonResult;
|
||||
use crate::imap::executor::ImapExecutor;
|
||||
use crate::message::content::AttachmentInfo;
|
||||
use crate::store::blob::{DetachedEmail, BLOB_MANAGER};
|
||||
use crate::store::tantivy::attachment::ATTACHMENT_MANAGER;
|
||||
use crate::store::tantivy::dedup_cache::DEDUP_CACHE;
|
||||
use crate::store::tantivy::envelope::ENVELOPE_MANAGER;
|
||||
use crate::store::tantivy::model::{AttachmentModel, EnvelopeWithAttachments};
|
||||
use crate::utils::html::extract_text;
|
||||
use crate::utils::{compute_content_hash, hex_hash};
|
||||
use crate::{id, store::envelope::Envelope};
|
||||
use crate::{raise_error, utc_now};
|
||||
use async_imap::types::Fetch;
|
||||
use bytes::Bytes;
|
||||
use mail_parser::{Address, HeaderName, Message, MessageParser, MimeHeaders};
|
||||
use tantivy::TantivyDocument;
|
||||
use tantivy::schema::Facet;
|
||||
use tantivy::{schema::Facet, TantivyDocument};
|
||||
use tracing::error;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{
|
||||
account::migration::AccountModel,
|
||||
archive::imap::mailbox::MailBox,
|
||||
common::AddrVec,
|
||||
envelope::{meta::parse_bichon_metadata, utils::normalize_subject},
|
||||
error::{code::ErrorCode, BichonResult},
|
||||
id,
|
||||
imap::executor::ImapExecutor,
|
||||
message::content::AttachmentInfo,
|
||||
raise_error,
|
||||
store::{
|
||||
blob::{DetachedEmail, BLOB_MANAGER},
|
||||
envelope::Envelope,
|
||||
tantivy::{
|
||||
attachment::ATTACHMENT_MANAGER,
|
||||
dedup_cache::DEDUP_CACHE,
|
||||
envelope::ENVELOPE_MANAGER,
|
||||
model::{AttachmentModel, EnvelopeWithAttachments},
|
||||
},
|
||||
},
|
||||
utc_now,
|
||||
utils::{compute_content_hash, hex_hash, html::extract_text},
|
||||
};
|
||||
|
||||
/// The outcome of extracting an envelope. `Duplicate` means the message was
|
||||
/// skipped because its content hash was already archived. `Imported` covers
|
||||
/// every other processed message, including mail dropped by archive rules,
|
||||
/// which has always counted as a success on the import surfaces.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[must_use]
|
||||
pub enum ExtractOutcome {
|
||||
Imported,
|
||||
Duplicate,
|
||||
}
|
||||
|
||||
pub async fn extract_envelope_and_store_it(
|
||||
fetch: Fetch,
|
||||
account_id: u64,
|
||||
@@ -64,14 +79,16 @@ pub async fn extract_envelope_and_store_it(
|
||||
}
|
||||
};
|
||||
let size = fetch.size.unwrap_or(body.len() as u32);
|
||||
extract_envelope_core(body, uid, size, internal_date, account_id, mailbox_id).await
|
||||
extract_envelope_core(body, uid, size, internal_date, account_id, mailbox_id)
|
||||
.await
|
||||
.map(|_| ())
|
||||
}
|
||||
|
||||
pub async fn extract_envelope_from_eml(
|
||||
body: &[u8],
|
||||
account_id: u64,
|
||||
mailbox_id: u64,
|
||||
) -> BichonResult<()> {
|
||||
) -> BichonResult<ExtractOutcome> {
|
||||
extract_envelope_core(body, 0, body.len() as u32, 0, account_id, mailbox_id).await
|
||||
}
|
||||
|
||||
@@ -79,7 +96,7 @@ pub async fn extract_envelope_from_smtp(
|
||||
body: &[u8],
|
||||
account_id: u64,
|
||||
mailbox_id: u64,
|
||||
) -> BichonResult<()> {
|
||||
) -> BichonResult<ExtractOutcome> {
|
||||
extract_envelope_core(
|
||||
body,
|
||||
0,
|
||||
@@ -98,13 +115,13 @@ async fn extract_envelope_core(
|
||||
internal_date: i64,
|
||||
account_id: u64,
|
||||
mailbox_id: u64,
|
||||
) -> BichonResult<()> {
|
||||
//The content hash of the original raw EML
|
||||
) -> BichonResult<ExtractOutcome> {
|
||||
// The content hash of the original raw EML
|
||||
let email_content_hash = compute_content_hash(body);
|
||||
if DEDUP_CACHE.contains(account_id, mailbox_id, &email_content_hash) {
|
||||
tracing::debug!("Duplicate email detected");
|
||||
//println!("Duplicate email detected");
|
||||
return Ok(());
|
||||
// println!("Duplicate email detected");
|
||||
return Ok(ExtractOutcome::Duplicate);
|
||||
}
|
||||
let message: Message<'_> = MessageParser::new().parse(body).ok_or_else(|| {
|
||||
raise_error!(
|
||||
@@ -116,7 +133,11 @@ async fn extract_envelope_core(
|
||||
if let Ok(account) = AccountModel::get(account_id) {
|
||||
if let Some(ref rules) = account.archive_rules {
|
||||
let sender = message.from().and_then(|addr| {
|
||||
AddrVec::from(addr).0.into_iter().next().and_then(|a| a.address)
|
||||
AddrVec::from(addr)
|
||||
.0
|
||||
.into_iter()
|
||||
.next()
|
||||
.and_then(|a| a.address)
|
||||
});
|
||||
let subject = message.subject().map(|s| s.to_string());
|
||||
|
||||
@@ -136,7 +157,7 @@ async fn extract_envelope_core(
|
||||
subject = subject.as_deref().unwrap_or("?"),
|
||||
"Email filtered out by archive rules"
|
||||
);
|
||||
return Ok(());
|
||||
return Ok(ExtractOutcome::Imported);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -202,12 +223,13 @@ async fn extract_envelope_core(
|
||||
.and_then(|add| add.address)
|
||||
.unwrap_or_else(|| "unknown".to_string());
|
||||
let attachment_count = message.attachment_count();
|
||||
let attachments = detach_and_store_attachments(body, &message, &email_content_hash, account_id, mailbox_id).await;
|
||||
let attachments =
|
||||
detach_and_store_attachments(body, &message, &email_content_hash, account_id, mailbox_id)
|
||||
.await;
|
||||
|
||||
let envelope_id = Uuid::new_v4().to_string();
|
||||
let now = utc_now!();
|
||||
|
||||
|
||||
let mut final_tags = Vec::new();
|
||||
|
||||
if let Some(meta_header) = message.header_raw("X-Bichon-Metadata") {
|
||||
@@ -215,11 +237,7 @@ async fn extract_envelope_core(
|
||||
if let Some(tags) = bmd.tags {
|
||||
let validated_tags: Result<Vec<String>, _> = tags
|
||||
.iter()
|
||||
.map(|tag| {
|
||||
Facet::from_text(tag)
|
||||
.map(|_| tag.clone())
|
||||
.map_err(|e| e)
|
||||
})
|
||||
.map(|tag| Facet::from_text(tag).map(|_| tag.clone()).map_err(|e| e))
|
||||
.collect();
|
||||
|
||||
match validated_tags {
|
||||
@@ -227,10 +245,7 @@ async fn extract_envelope_core(
|
||||
final_tags = valid_list;
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!(
|
||||
"Tag validation failed, ignoring all tags: {:#?}",
|
||||
e
|
||||
);
|
||||
eprintln!("Tag validation failed, ignoring all tags: {:#?}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -317,7 +332,7 @@ async fn extract_envelope_core(
|
||||
for doc in attachment_docs {
|
||||
ATTACHMENT_MANAGER.queue(doc).await;
|
||||
}
|
||||
Ok(())
|
||||
Ok(ExtractOutcome::Imported)
|
||||
}
|
||||
|
||||
pub fn extract_envelope_from_nested_message(
|
||||
@@ -453,7 +468,8 @@ pub async fn detach_and_store_attachments(
|
||||
|
||||
let mut stripped_eml = original_body.to_vec();
|
||||
let mut attachment_infos = Vec::new();
|
||||
// Step 1: Collect and sort attachment ranges in reverse to maintain offset integrity
|
||||
// Step 1: Collect and sort attachment ranges in reverse to maintain offset
|
||||
// integrity
|
||||
let mut ranges: Vec<_> = message
|
||||
.attachments()
|
||||
.map(|att| {
|
||||
@@ -573,10 +589,8 @@ pub async fn detach_and_store_attachments(
|
||||
// Run text extraction in a single spawn_blocking batch.
|
||||
if !text_candidates.is_empty() {
|
||||
if let Ok(mut extracted_map) = tokio::task::spawn_blocking(move || {
|
||||
let mut map: std::collections::HashMap<
|
||||
String,
|
||||
(String, Option<u32>, bool),
|
||||
> = std::collections::HashMap::new();
|
||||
let mut map: std::collections::HashMap<String, (String, Option<u32>, bool)> =
|
||||
std::collections::HashMap::new();
|
||||
for c in text_candidates {
|
||||
if let Some(r) =
|
||||
crate::ext::text_extractor::extract_text(&c.file_type, &c.ext, &c.bytes)
|
||||
@@ -613,8 +627,7 @@ pub fn reattach_eml_content(
|
||||
envelope_id: String,
|
||||
) -> BichonResult<(Envelope, Bytes)> {
|
||||
let e = ENVELOPE_MANAGER
|
||||
.get_envelope_by_id(account_id, &envelope_id)
|
||||
?
|
||||
.get_envelope_by_id(account_id, &envelope_id)?
|
||||
.ok_or_else(|| {
|
||||
raise_error!(
|
||||
format!(
|
||||
@@ -647,7 +660,7 @@ pub fn reattach_eml_content(
|
||||
return Err(raise_error!(
|
||||
format!(
|
||||
"Consistency check failed: envelope.attachment_count ({}) does not match attachments.len ({})",
|
||||
e.envelope.attachment_count,
|
||||
e.envelope.attachment_count,
|
||||
actual_count
|
||||
),
|
||||
ErrorCode::InternalError
|
||||
@@ -668,11 +681,7 @@ pub fn reattach_eml_content(
|
||||
let absolute_start = search_cursor + pos;
|
||||
let absolute_end = absolute_start + pattern_len;
|
||||
|
||||
tasks.push((
|
||||
absolute_start,
|
||||
absolute_end,
|
||||
detail.content_hash.clone(),
|
||||
));
|
||||
tasks.push((absolute_start, absolute_end, detail.content_hash.clone()));
|
||||
search_cursor = absolute_end;
|
||||
}
|
||||
}
|
||||
@@ -690,14 +699,15 @@ pub fn reattach_eml_content(
|
||||
Ok((e.envelope, Bytes::from(restored_eml)))
|
||||
}
|
||||
|
||||
/// Returns the raw EML for an indexed message, self-healing a missing content blob.
|
||||
/// Returns the raw EML for an indexed message, self-healing a missing content
|
||||
/// blob.
|
||||
///
|
||||
/// Behaves like [`reattach_eml_content`], but when the message's content blob is
|
||||
/// absent from the blob store it fetches that single message on demand from the
|
||||
/// IMAP server (`UID FETCH <uid> (BODY.PEEK[])`), persists it for future requests,
|
||||
/// and returns it. If the on-demand fetch itself fails, the original "content not
|
||||
/// found" error from [`reattach_eml_content`] is surfaced unchanged so the caller
|
||||
/// still produces its 404.
|
||||
/// Behaves like [`reattach_eml_content`], but when the message's content blob
|
||||
/// is absent from the blob store it fetches that single message on demand from
|
||||
/// the IMAP server (`UID FETCH <uid> (BODY.PEEK[])`), persists it for future
|
||||
/// requests, and returns it. If the on-demand fetch itself fails, the original
|
||||
/// "content not found" error from [`reattach_eml_content`] is surfaced
|
||||
/// unchanged so the caller still produces its 404.
|
||||
pub async fn reattach_eml_content_self_healing(
|
||||
account_id: u64,
|
||||
envelope_id: String,
|
||||
@@ -746,14 +756,15 @@ pub async fn reattach_eml_content_self_healing(
|
||||
|
||||
/// Fetches one message from IMAP and re-stores its detached blob.
|
||||
///
|
||||
/// On success the freshly fetched raw RFC822 body is returned; it is also queued
|
||||
/// (in detached form) into the blob store so subsequent requests hit the cache.
|
||||
/// Fails if the message cannot be fetched, or if the fetched bytes do not match
|
||||
/// the archived `content_hash` (the server-side message no longer matches what
|
||||
/// Bichon archived, so it cannot be treated as a recovery of that blob).
|
||||
/// On success the freshly fetched raw RFC822 body is returned; it is also
|
||||
/// queued (in detached form) into the blob store so subsequent requests hit the
|
||||
/// cache. Fails if the message cannot be fetched, or if the fetched bytes do
|
||||
/// not match the archived `content_hash` (the server-side message no longer
|
||||
/// matches what Bichon archived, so it cannot be treated as a recovery of that
|
||||
/// blob).
|
||||
async fn recover_message_blob(envelope: &Envelope) -> BichonResult<Bytes> {
|
||||
let mailbox = MailBox::find_mailbox(envelope.account_id, envelope.mailbox_id)?
|
||||
.ok_or_else(|| {
|
||||
let mailbox =
|
||||
MailBox::find_mailbox(envelope.account_id, envelope.mailbox_id)?.ok_or_else(|| {
|
||||
raise_error!(
|
||||
format!(
|
||||
"Mailbox not found: account_id={} mailbox_id={}",
|
||||
@@ -787,13 +798,22 @@ async fn recover_message_blob(envelope: &Envelope) -> BichonResult<Bytes> {
|
||||
// Re-create the detached blob (stripped EML + attachments) so the missing
|
||||
// blob is repopulated for future requests. The detached EML is queued under
|
||||
// `fetched_hash`, which equals `envelope.content_hash`.
|
||||
let message = MessageParser::new().parse(raw_body.as_slice()).ok_or_else(|| {
|
||||
raise_error!(
|
||||
"Failed to parse fetched email content".into(),
|
||||
ErrorCode::InternalError
|
||||
)
|
||||
})?;
|
||||
detach_and_store_attachments(&raw_body, &message, &fetched_hash, envelope.account_id, envelope.mailbox_id).await;
|
||||
let message = MessageParser::new()
|
||||
.parse(raw_body.as_slice())
|
||||
.ok_or_else(|| {
|
||||
raise_error!(
|
||||
"Failed to parse fetched email content".into(),
|
||||
ErrorCode::InternalError
|
||||
)
|
||||
})?;
|
||||
detach_and_store_attachments(
|
||||
&raw_body,
|
||||
&message,
|
||||
&fetched_hash,
|
||||
envelope.account_id,
|
||||
envelope.mailbox_id,
|
||||
)
|
||||
.await;
|
||||
|
||||
Ok(Bytes::from(raw_body))
|
||||
}
|
||||
@@ -886,14 +906,9 @@ mod test {
|
||||
assert!(truncated.len() < raw.len());
|
||||
|
||||
// Must not panic.
|
||||
let infos = super::detach_and_store_attachments(
|
||||
truncated,
|
||||
&message,
|
||||
"test_content_hash",
|
||||
0,
|
||||
0,
|
||||
)
|
||||
.await;
|
||||
let infos =
|
||||
super::detach_and_store_attachments(truncated, &message, "test_content_hash", 0, 0)
|
||||
.await;
|
||||
|
||||
// The attachment count must still match so the consistency check
|
||||
// in reattach_eml_content doesn't fail later.
|
||||
|
||||
@@ -26,7 +26,13 @@
|
||||
// It never reads from the event bus — events are fire-and-forget.
|
||||
|
||||
use std::net::IpAddr;
|
||||
use std::sync::{LazyLock, RwLock};
|
||||
use std::sync::{LazyLock, Mutex, RwLock};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// Free-form JSON value carried by events that need a content snapshot
|
||||
/// (e.g. the subject / attachment names of a message being deleted, so the
|
||||
/// audit trail stays self-describing after the content is gone).
|
||||
pub type EventPayload = serde_json::Map<String, serde_json::Value>;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Event {
|
||||
@@ -34,10 +40,58 @@ pub enum Event {
|
||||
email_id: String,
|
||||
user: String,
|
||||
ip: IpAddr,
|
||||
account_id: u64,
|
||||
mailbox_id: u64,
|
||||
/// Subject of the viewed message, so the audit trail is readable
|
||||
/// without resolving the envelope again.
|
||||
subject: Option<String>,
|
||||
},
|
||||
EmailDeleted {
|
||||
email_id: String,
|
||||
user: String,
|
||||
account_id: u64,
|
||||
mailbox_id: u64,
|
||||
/// Subject at deletion time (the message is gone afterwards).
|
||||
subject: Option<String>,
|
||||
/// Snapshot of the deleted message (attachment names, from/to, ...).
|
||||
snapshot: Option<EventPayload>,
|
||||
},
|
||||
/// Raw EML file downloaded (export).
|
||||
EmailExported {
|
||||
email_id: String,
|
||||
user: String,
|
||||
account_id: u64,
|
||||
subject: Option<String>,
|
||||
},
|
||||
/// Email restored back to the source IMAP server.
|
||||
EmailRestored {
|
||||
email_id: String,
|
||||
user: String,
|
||||
account_id: u64,
|
||||
subject: Option<String>,
|
||||
},
|
||||
/// Facet tags added/removed on emails.
|
||||
EmailTagged {
|
||||
user: String,
|
||||
account_id: u64,
|
||||
/// Number of emails whose tags were changed.
|
||||
count: u64,
|
||||
},
|
||||
/// Facet tags added/removed on attachments.
|
||||
AttachmentTagged {
|
||||
user: String,
|
||||
account_id: u64,
|
||||
/// Number of attachments whose tags were changed.
|
||||
count: u64,
|
||||
},
|
||||
/// Attachment content streamed for in-browser preview (not a download).
|
||||
AttachmentPreviewed {
|
||||
email_id: String,
|
||||
content_hash: String,
|
||||
user: String,
|
||||
account_id: u64,
|
||||
mailbox_id: u64,
|
||||
filename: Option<String>,
|
||||
},
|
||||
UserLoggedIn {
|
||||
user: String,
|
||||
@@ -47,6 +101,26 @@ pub enum Event {
|
||||
created_by: String,
|
||||
new_user: String,
|
||||
},
|
||||
UserUpdated {
|
||||
updated_by: String,
|
||||
target_user: String,
|
||||
},
|
||||
UserRemoved {
|
||||
removed_by: String,
|
||||
target_user: String,
|
||||
},
|
||||
RoleCreated {
|
||||
created_by: String,
|
||||
role_name: String,
|
||||
},
|
||||
RoleUpdated {
|
||||
updated_by: String,
|
||||
role_name: String,
|
||||
},
|
||||
RoleRemoved {
|
||||
removed_by: String,
|
||||
role_name: String,
|
||||
},
|
||||
SearchPerformed {
|
||||
query: String,
|
||||
user: String,
|
||||
@@ -57,8 +131,116 @@ pub enum Event {
|
||||
},
|
||||
AttachmentDownloaded {
|
||||
email_id: String,
|
||||
/// The attachment's own content hash.
|
||||
content_hash: String,
|
||||
user: String,
|
||||
account_id: u64,
|
||||
mailbox_id: u64,
|
||||
filename: Option<String>,
|
||||
size: Option<u64>,
|
||||
ext: Option<String>,
|
||||
/// Content hash of the parent email (EML), when known.
|
||||
parent_content_hash: Option<String>,
|
||||
},
|
||||
AccountCreated {
|
||||
created_by: String,
|
||||
account_id: u64,
|
||||
email: String,
|
||||
},
|
||||
AccountUpdated {
|
||||
updated_by: String,
|
||||
account_id: u64,
|
||||
email: String,
|
||||
},
|
||||
AccountRemoved {
|
||||
removed_by: String,
|
||||
account_id: u64,
|
||||
email: String,
|
||||
},
|
||||
AccountDownloadStarted {
|
||||
user: String,
|
||||
account_id: u64,
|
||||
run_gap_fill: bool,
|
||||
},
|
||||
AccountDownloadStopped {
|
||||
user: String,
|
||||
account_id: u64,
|
||||
},
|
||||
/// Batch role / access assignment on one or more accounts.
|
||||
AccountRoleAssigned {
|
||||
user: String,
|
||||
target_user: String,
|
||||
account_count: usize,
|
||||
roles: Vec<String>,
|
||||
},
|
||||
AccessTokenCreated {
|
||||
user: String,
|
||||
target_user: String,
|
||||
name: Option<String>,
|
||||
},
|
||||
AccessTokenRemoved {
|
||||
user: String,
|
||||
token_user: String,
|
||||
name: Option<String>,
|
||||
},
|
||||
OAuth2ConfigCreated {
|
||||
user: String,
|
||||
oauth2_id: u64,
|
||||
name: String,
|
||||
},
|
||||
OAuth2ConfigUpdated {
|
||||
user: String,
|
||||
oauth2_id: u64,
|
||||
name: String,
|
||||
},
|
||||
OAuth2ConfigRemoved {
|
||||
user: String,
|
||||
oauth2_id: u64,
|
||||
name: String,
|
||||
},
|
||||
/// External OAuth2 token stored / refreshed for an account.
|
||||
OAuth2TokenStored {
|
||||
user: String,
|
||||
account_id: u64,
|
||||
},
|
||||
ImportPerformed {
|
||||
user: String,
|
||||
account_id: u64,
|
||||
format: String,
|
||||
total: u64,
|
||||
success: u64,
|
||||
duplicates: u64,
|
||||
failed: u64,
|
||||
},
|
||||
MailboxRemoved {
|
||||
user: String,
|
||||
account_id: u64,
|
||||
mailbox_id: u64,
|
||||
},
|
||||
ProxyCreated {
|
||||
user: String,
|
||||
url: String,
|
||||
},
|
||||
ProxyUpdated {
|
||||
user: String,
|
||||
url: String,
|
||||
},
|
||||
ProxyRemoved {
|
||||
user: String,
|
||||
url: String,
|
||||
},
|
||||
/// Pro edition: SSO (OIDC) login, logout, or license upload.
|
||||
SsoLogin {
|
||||
user: String,
|
||||
ip: Option<IpAddr>,
|
||||
},
|
||||
SsoLogout {
|
||||
user: String,
|
||||
},
|
||||
LicenseUploaded {
|
||||
user: String,
|
||||
email: String,
|
||||
edition: String,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -75,6 +257,53 @@ impl EventBus for NoopEventBus {
|
||||
static EVENT_BUS: LazyLock<RwLock<Box<dyn EventBus>>> =
|
||||
LazyLock::new(|| RwLock::new(Box::new(NoopEventBus)));
|
||||
|
||||
/// Short-window dedup of view/download events.
|
||||
///
|
||||
/// The web UI can fire duplicate `message-content` requests for the same
|
||||
/// email (React StrictMode double-effects, remote-content toggle, thread
|
||||
/// expansion). Deduping here keeps the audit trail to one record per
|
||||
/// intentional view without hiding repeated deliberate accesses.
|
||||
static VIEW_DEDUP: LazyLock<Mutex<Vec<(String, Instant)>>> =
|
||||
LazyLock::new(|| Mutex::new(Vec::new()));
|
||||
|
||||
const VIEW_DEDUP_WINDOW: Duration = Duration::from_secs(10);
|
||||
|
||||
fn is_duplicate_view(event: &Event) -> bool {
|
||||
let key = match event {
|
||||
Event::EmailViewed {
|
||||
user, email_id, ..
|
||||
} => Some(format!("email.viewed|{user}|{email_id}")),
|
||||
Event::EmailDeleted {
|
||||
user, email_id, ..
|
||||
} => Some(format!("email.deleted|{user}|{email_id}")),
|
||||
Event::AttachmentDownloaded {
|
||||
user,
|
||||
email_id,
|
||||
content_hash,
|
||||
..
|
||||
} => Some(format!("attachment.downloaded|{user}|{email_id}|{content_hash}")),
|
||||
Event::AttachmentPreviewed {
|
||||
user,
|
||||
email_id,
|
||||
content_hash,
|
||||
..
|
||||
} => Some(format!("attachment.previewed|{user}|{email_id}|{content_hash}")),
|
||||
_ => None,
|
||||
};
|
||||
let Some(key) = key else {
|
||||
return false;
|
||||
};
|
||||
|
||||
let mut entries = VIEW_DEDUP.lock().unwrap();
|
||||
let now = Instant::now();
|
||||
entries.retain(|(_, at)| now.duration_since(*at) < VIEW_DEDUP_WINDOW);
|
||||
if entries.iter().any(|(k, _)| *k == key) {
|
||||
return true;
|
||||
}
|
||||
entries.push((key, now));
|
||||
false
|
||||
}
|
||||
|
||||
/// Called by Pro/Enterprise at startup to replace the noop default.
|
||||
pub fn set_event_bus(bus: Box<dyn EventBus>) {
|
||||
*EVENT_BUS.write().unwrap() = bus;
|
||||
@@ -82,5 +311,8 @@ pub fn set_event_bus(bus: Box<dyn EventBus>) {
|
||||
|
||||
/// Fire-and-forget. Called by the server at key points.
|
||||
pub fn emit(event: Event) {
|
||||
if is_duplicate_view(&event) {
|
||||
return;
|
||||
}
|
||||
EVENT_BUS.read().unwrap().emit(event);
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
|
||||
use crate::account::migration::AccountModel;
|
||||
use crate::account::state::{DownloadState, DownloadStatus, FolderStatus};
|
||||
use crate::cache::imap::mailbox::MailBox;
|
||||
use crate::archive::imap::mailbox::MailBox;
|
||||
use crate::envelope::extractor::extract_envelope_and_store_it;
|
||||
use crate::error::code::ErrorCode;
|
||||
use crate::imap::session::SessionStream;
|
||||
@@ -124,10 +124,12 @@ impl ImapExecutor {
|
||||
|
||||
/// Fetches new mail for a mailbox.
|
||||
///
|
||||
/// When `before` is `Some(date)`, a two-step approach is used:
|
||||
/// `UID SEARCH` to find matching UIDs (standard IMAP), then batch `UID FETCH`
|
||||
/// for the specific UIDs. When `before` is `None`, a direct ranged
|
||||
/// `UID FETCH {start}:*` is issued and results are streamed.
|
||||
/// UIDs are enumerated via a ranged `UID FETCH {start}:* (UID RFC822.SIZE
|
||||
/// INTERNALDATE)` (RFC 3501 §6.4.4 closed-interval semantics — unlike
|
||||
/// `UID SEARCH`, which servers may answer with a subset, a truncated
|
||||
/// enumeration cannot silently skip messages), then bodies are downloaded
|
||||
/// in batches. When `before` is `Some(date)`, the INTERNALDATE is compared
|
||||
/// against the date client-side (equivalent to SEARCH's BEFORE key).
|
||||
///
|
||||
/// Returns `Ok(Some(max_uid))` with the highest UID fetched, or `Ok(None)`
|
||||
/// if no new mail was found.
|
||||
@@ -141,63 +143,54 @@ impl ImapExecutor {
|
||||
) -> BichonResult<Option<u32>> {
|
||||
assert!(start_uid > 0, "start_uid must be greater than 0");
|
||||
|
||||
session
|
||||
let examined = session
|
||||
.examine(&mailbox.encoded_name())
|
||||
.await
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), classify_imap_error(&e)))?;
|
||||
|
||||
match before {
|
||||
Some(date) => {
|
||||
Self::fetch_new_mail_with_before(session, account, mailbox, start_uid, date, token)
|
||||
Self::fetch_new_mail_with_before(
|
||||
session, account, mailbox, start_uid, date, examined, token,
|
||||
)
|
||||
.await
|
||||
}
|
||||
None => {
|
||||
Self::fetch_new_mail_range(session, account, mailbox, start_uid, examined, token)
|
||||
.await
|
||||
}
|
||||
None => Self::fetch_new_mail_range(session, account, mailbox, start_uid, token).await,
|
||||
}
|
||||
}
|
||||
|
||||
/// Two-step approach for date-filtered incremental fetch: UID SEARCH first,
|
||||
/// then batch UID FETCH for matching UIDs. Uses standard IMAP syntax that
|
||||
/// works across all compliant servers.
|
||||
/// Date-filtered incremental fetch: enumerate UIDs in `{start}:*` via
|
||||
/// ranged `UID FETCH` (RFC 3501 §6.4.4 closed-interval semantics, so a
|
||||
/// truncated result cannot silently skip messages), then filter by
|
||||
/// INTERNALDATE client-side and batch-download the bodies.
|
||||
///
|
||||
/// The `BEFORE {date}` filter is applied locally: RFC 3501's BEFORE key
|
||||
/// matches messages whose internal date (ignoring time and timezone) is
|
||||
/// earlier than the given date, which is exactly the same comparison done
|
||||
/// here on the INTERNALDATE returned by the enumeration fetch.
|
||||
async fn fetch_new_mail_with_before(
|
||||
session: &mut Session<Box<dyn SessionStream>>,
|
||||
account: &AccountModel,
|
||||
mailbox: &MailBox,
|
||||
start_uid: u64,
|
||||
date: &str,
|
||||
examined: async_imap::types::Mailbox,
|
||||
token: CancellationToken,
|
||||
) -> BichonResult<Option<u32>> {
|
||||
let query = format!("UID {start_uid}:* BEFORE {date}");
|
||||
info!(
|
||||
"[account {}][mailbox {}] fetch_new_mail: UID SEARCH {}",
|
||||
account.id, mailbox.name, query
|
||||
);
|
||||
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), classify_imap_error(&e))
|
||||
})?;
|
||||
|
||||
if results.is_empty() {
|
||||
info!(
|
||||
account_id = account.id,
|
||||
mailbox = %mailbox.name,
|
||||
start_uid,
|
||||
date,
|
||||
"fetch_new_mail_with_before: UID SEARCH returned no results"
|
||||
);
|
||||
DownloadState::update_folder_progress(
|
||||
account.id,
|
||||
mailbox.name.clone(),
|
||||
0,
|
||||
0,
|
||||
FolderStatus::Success,
|
||||
Some("No new emails found.".into()),
|
||||
)?;
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let mut uid_vec: Vec<u32> = results.into_iter().collect();
|
||||
uid_vec.sort();
|
||||
let uid_range = format!("{start_uid}:*");
|
||||
let (entries, skipped_oversized) = Self::collect_range_uids(
|
||||
session,
|
||||
&uid_range,
|
||||
account.id,
|
||||
mailbox,
|
||||
account.max_email_size_bytes,
|
||||
token.clone(),
|
||||
)
|
||||
.await?;
|
||||
let mut uid_vec = filter_before_date(&entries, date)?;
|
||||
// Same non-compliant-server guard as fetch_new_mail_range: `{start}:*`
|
||||
// may be clamped by the server and return uids below start_uid, which
|
||||
// are already stored locally (or are drift — gap-fill's job).
|
||||
@@ -210,8 +203,54 @@ impl ImapExecutor {
|
||||
found = uid_vec.len(),
|
||||
first = uid_vec.first().copied(),
|
||||
last = uid_vec.last().copied(),
|
||||
"fetch_new_mail_with_before: UID SEARCH result"
|
||||
skipped_oversized,
|
||||
"fetch_new_mail_with_before: UID FETCH result"
|
||||
);
|
||||
|
||||
if uid_vec.is_empty() {
|
||||
// Same truncated-result guard as fetch_new_mail_range: if the
|
||||
// server claims new mail but nothing passed the filters, refuse to
|
||||
// advance highest_uid. A legitimate empty result here is: mail
|
||||
// existed but was all oversized (skipped_oversized > 0), or all
|
||||
// entries fell outside the date window (entries non-empty).
|
||||
if skipped_oversized == 0 && entries.is_empty() {
|
||||
if let Some(msg) = empty_enumeration_anomaly(
|
||||
mailbox.name.as_str(),
|
||||
&uid_range,
|
||||
start_uid,
|
||||
examined.uid_next,
|
||||
) {
|
||||
tracing::info!(
|
||||
account_id = account.id,
|
||||
mailbox = %mailbox.name,
|
||||
start_uid,
|
||||
uid_next = examined.uid_next,
|
||||
"{}",
|
||||
msg
|
||||
);
|
||||
//DownloadState::append_session_error(account.id, msg)?;
|
||||
DownloadState::update_folder_progress(
|
||||
account.id,
|
||||
mailbox.name.clone(),
|
||||
0,
|
||||
0,
|
||||
FolderStatus::Success,
|
||||
Some("UID FETCH range returned empty; tail UIDs appear to have been purged. highest_uid unchanged, will retry on next sync.".into()),
|
||||
)?;
|
||||
return Ok(None);
|
||||
}
|
||||
}
|
||||
DownloadState::update_folder_progress(
|
||||
account.id,
|
||||
mailbox.name.clone(),
|
||||
0,
|
||||
0,
|
||||
FolderStatus::Success,
|
||||
Some("No new emails found.".into()),
|
||||
)?;
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let max_uid = uid_vec.last().copied();
|
||||
let planned = uid_vec.len() as u64;
|
||||
let batch_size = account.download_batch_size.unwrap_or(DEFAULT_BATCH_SIZE) as usize;
|
||||
@@ -297,34 +336,124 @@ impl ImapExecutor {
|
||||
Ok(max_uid)
|
||||
}
|
||||
|
||||
/// Enumerates every UID in `{start}:*` via a lightweight
|
||||
/// `UID FETCH {start}:* (UID RFC822.SIZE INTERNALDATE)` — no bodies.
|
||||
///
|
||||
/// Unlike `UID SEARCH`, a ranged UID FETCH is required by RFC 3501 §6.4.4
|
||||
/// to return the full closed interval, so a truncated response cannot
|
||||
/// silently skip messages in the middle of the range.
|
||||
///
|
||||
/// UIDs whose RFC822.SIZE exceeds `max_email_size_bytes` are dropped here
|
||||
/// (they would be skipped again by the batched body fetch's SIZE pre-check,
|
||||
/// so filtering up front saves a round trip per batch). Returns the
|
||||
/// accepted `(uid, size, internal_date_epoch_millis)` entries and the
|
||||
/// number of oversized messages skipped.
|
||||
async fn collect_range_uids(
|
||||
session: &mut Session<Box<dyn SessionStream>>,
|
||||
uid_range: &str,
|
||||
account_id: u64,
|
||||
mailbox: &MailBox,
|
||||
max_email_size_bytes: Option<u64>,
|
||||
token: CancellationToken,
|
||||
) -> BichonResult<(Vec<(u32, u64, i64)>, u64)> {
|
||||
let limit = max_email_size_bytes.unwrap_or(DEFAULT_MAX_EMAIL_SIZE);
|
||||
let mut uid_stream = session
|
||||
.uid_fetch(uid_range, "(UID RFC822.SIZE INTERNALDATE)")
|
||||
.await
|
||||
.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), classify_imap_error(&e))
|
||||
})?;
|
||||
let mut entries: Vec<(u32, u64, i64)> = Vec::new();
|
||||
let mut skipped_oversized = 0u64;
|
||||
while let Some(fetch) = uid_stream.try_next().await.map_err(|e| {
|
||||
let err_msg = format!("UID FETCH stream failed in [{}]: {:#?}", mailbox.name, e);
|
||||
let _ = DownloadState::append_session_error(account_id, err_msg);
|
||||
raise_error!(format!("{:#?}", e), classify_imap_error(&e))
|
||||
})? {
|
||||
if token.is_cancelled() {
|
||||
DownloadState::update_session_status(
|
||||
account_id,
|
||||
DownloadStatus::Cancelled,
|
||||
Some("User stopped or system shutdown".to_string()),
|
||||
)?;
|
||||
return Err(raise_error!(
|
||||
"Stream cancelled".into(),
|
||||
ErrorCode::InternalError
|
||||
));
|
||||
}
|
||||
let Some(uid) = fetch.uid else {
|
||||
continue;
|
||||
};
|
||||
let size = fetch.size.unwrap_or(0) as u64;
|
||||
let internal_date = fetch
|
||||
.internal_date()
|
||||
.map(|d| d.timestamp_millis())
|
||||
.unwrap_or(0);
|
||||
if size == 0 || size <= limit {
|
||||
entries.push((uid, size, internal_date));
|
||||
} else {
|
||||
skipped_oversized += 1;
|
||||
tracing::warn!(
|
||||
account_id,
|
||||
mailbox_id = mailbox.id,
|
||||
uid,
|
||||
size,
|
||||
limit,
|
||||
"Skipping oversized email during UID enumeration"
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok((entries, skipped_oversized))
|
||||
}
|
||||
|
||||
/// Fetches all messages with UID >= start_uid via batched UID FETCH.
|
||||
///
|
||||
/// A single ranged `UID FETCH {start}:*` can block for minutes on slow
|
||||
/// servers pushing hundreds of messages, and hits the socket read timeout
|
||||
/// if the server stalls, with zero progress feedback in the meantime.
|
||||
/// Instead, enumerate the UIDs first, then download in small batches —
|
||||
/// each batch is a short round-trip with a SIZE pre-check (oversized
|
||||
/// messages are skipped without fetching their body), progress is reported
|
||||
/// per batch, and the whole download stays responsive to cancellation.
|
||||
/// A single ranged `UID FETCH {start}:* (BODY[])` can block for minutes on
|
||||
/// slow servers pushing hundreds of messages, and hits the socket read
|
||||
/// timeout if the server stalls, with zero progress feedback in the
|
||||
/// meantime. Instead, enumerate the UIDs first via a lightweight
|
||||
/// `UID FETCH {start}:* (UID RFC822.SIZE)` (headers/size only, no bodies),
|
||||
/// then download in small batches — each batch is a short round-trip with
|
||||
/// a SIZE pre-check (oversized messages are skipped without fetching their
|
||||
/// body), progress is reported per batch, and the whole download stays
|
||||
/// responsive to cancellation.
|
||||
///
|
||||
/// A plain `UID SEARCH {start}:*` is NOT used to enumerate: RFC 3501
|
||||
/// grants SEARCH the freedom to return a subset or non-normalized results,
|
||||
/// and servers in the wild (e.g. Gmail) occasionally return only the last
|
||||
/// matching UID for a huge range. Since the caller advances highest_uid to
|
||||
/// the last UID found, a truncated SEARCH permanently skips everything
|
||||
/// between start_uid and that last UID. UID FETCH on a range, by contrast,
|
||||
/// is REQUIRED by RFC 3501 §6.4.4 to return the full closed interval
|
||||
/// [start_uid, max UID].
|
||||
async fn fetch_new_mail_range(
|
||||
session: &mut Session<Box<dyn SessionStream>>,
|
||||
account: &AccountModel,
|
||||
mailbox: &MailBox,
|
||||
start_uid: u64,
|
||||
examined: async_imap::types::Mailbox,
|
||||
token: CancellationToken,
|
||||
) -> BichonResult<Option<u32>> {
|
||||
let uid_range = format!("{start_uid}:*");
|
||||
info!(
|
||||
"[account {}][mailbox {}] fetch_new_mail: batched UID FETCH {}",
|
||||
"[account {}][mailbox {}] fetch_new_mail: enumerate UIDs via UID FETCH {}",
|
||||
account.id, mailbox.name, uid_range
|
||||
);
|
||||
|
||||
let results = session.uid_search(&uid_range).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), classify_imap_error(&e))
|
||||
})?;
|
||||
let mut uid_vec: Vec<u32> = results.into_iter().collect();
|
||||
// Track how many messages were dropped by the size filter so an empty
|
||||
// result is not misread as an enumeration failure (anomaly guard).
|
||||
let (entries, skipped_oversized) = Self::collect_range_uids(
|
||||
session,
|
||||
&uid_range,
|
||||
account.id,
|
||||
mailbox,
|
||||
account.max_email_size_bytes,
|
||||
token.clone(),
|
||||
)
|
||||
.await?;
|
||||
let mut uid_vec: Vec<u32> = entries.iter().map(|&(uid, _, _)| uid).collect();
|
||||
uid_vec.sort();
|
||||
// Some non-compliant servers (e.g. Zoho) interpret `{start}:*` as a
|
||||
// sequence range and clamp it, returning the last message even when
|
||||
@@ -339,10 +468,50 @@ impl ImapExecutor {
|
||||
found = uid_vec.len(),
|
||||
first = uid_vec.first().copied(),
|
||||
last = uid_vec.last().copied(),
|
||||
"fetch_new_mail_range: UID SEARCH result"
|
||||
skipped_oversized,
|
||||
"fetch_new_mail_range: UID FETCH result"
|
||||
);
|
||||
|
||||
if uid_vec.is_empty() {
|
||||
// Guard against a truncated (or silently dropped) FETCH result:
|
||||
// if the server reports a UIDNEXT well above start_uid yet no UIDs
|
||||
// came back, do NOT advance highest_uid past start_uid — that would
|
||||
// permanently skip everything in between. Report the anomaly and
|
||||
// keep the old highest_uid so the next sync retries.
|
||||
//
|
||||
// Oversized-only mail is legitimate (nothing to download within the
|
||||
// size limit), so skip the anomaly check when the size filter (not
|
||||
// the server) is what emptied the range.
|
||||
if skipped_oversized == 0 {
|
||||
if let Some(msg) = empty_enumeration_anomaly(
|
||||
mailbox.name.as_str(),
|
||||
&uid_range,
|
||||
start_uid,
|
||||
examined.uid_next,
|
||||
) {
|
||||
tracing::info!(
|
||||
account_id = account.id,
|
||||
mailbox = %mailbox.name,
|
||||
start_uid,
|
||||
uid_next = examined.uid_next,
|
||||
"{}",
|
||||
msg
|
||||
);
|
||||
//The gap between highest_uid and uid_next may be caused by messages being deleted on the server side.
|
||||
//Reporting this as a warning/error makes users think that something is wrong with the sync.
|
||||
|
||||
//DownloadState::append_session_error(account.id, msg)?;
|
||||
DownloadState::update_folder_progress(
|
||||
account.id,
|
||||
mailbox.name.clone(),
|
||||
0,
|
||||
0,
|
||||
FolderStatus::Success,
|
||||
Some("UID FETCH range returned empty; tail UIDs appear to have been purged. highest_uid unchanged, will retry on next sync.".into()),
|
||||
)?;
|
||||
return Ok(None);
|
||||
}
|
||||
}
|
||||
DownloadState::update_folder_progress(
|
||||
account.id,
|
||||
mailbox.name.clone(),
|
||||
@@ -584,39 +753,36 @@ impl ImapExecutor {
|
||||
// On error, report the number of emails already stored through the
|
||||
// progress callback so a partial batch is not counted as fully
|
||||
// failed by callers (gap_fill uses the last reported count).
|
||||
let item = match tokio::time::timeout(STALL_REPORT_INTERVAL, body_stream.try_next()).await
|
||||
{
|
||||
Ok(Ok(item)) => Ok(item),
|
||||
Ok(Err(e)) => Err((count, e)),
|
||||
Err(_) => {
|
||||
if token.is_cancelled() {
|
||||
if let Some(progress) = progress {
|
||||
let _ = progress(count, None, None);
|
||||
let item =
|
||||
match tokio::time::timeout(STALL_REPORT_INTERVAL, body_stream.try_next()).await {
|
||||
Ok(Ok(item)) => Ok(item),
|
||||
Ok(Err(e)) => Err((count, e)),
|
||||
Err(_) => {
|
||||
if token.is_cancelled() {
|
||||
if let Some(progress) = progress {
|
||||
let _ = progress(count, None, None);
|
||||
}
|
||||
return Err(raise_error!(
|
||||
"Stream cancelled".into(),
|
||||
ErrorCode::InternalError
|
||||
));
|
||||
}
|
||||
return Err(raise_error!(
|
||||
"Stream cancelled".into(),
|
||||
ErrorCode::InternalError
|
||||
));
|
||||
}
|
||||
let stall_secs = last_recv.elapsed().as_secs_f64();
|
||||
consecutive_stalls += 1;
|
||||
let stall_secs = last_recv.elapsed().as_secs_f64();
|
||||
consecutive_stalls += 1;
|
||||
|
||||
if let Some(progress) = progress {
|
||||
progress(count, None, Some(stall_secs))?;
|
||||
if let Some(progress) = progress {
|
||||
progress(count, None, Some(stall_secs))?;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
};
|
||||
};
|
||||
let item = match item {
|
||||
Ok(item) => item,
|
||||
Err((processed, e)) => {
|
||||
if let Some(progress) = progress {
|
||||
let _ = progress(processed, None, None);
|
||||
}
|
||||
return Err(raise_error!(
|
||||
format!("{:#?}", e),
|
||||
classify_imap_error(&e)
|
||||
));
|
||||
return Err(raise_error!(format!("{:#?}", e), classify_imap_error(&e)));
|
||||
}
|
||||
};
|
||||
let Some(fetch) = item else { break };
|
||||
@@ -768,12 +934,13 @@ impl ImapExecutor {
|
||||
session: &mut Session<Box<dyn SessionStream>>,
|
||||
uid_set: &str,
|
||||
token: CancellationToken,
|
||||
progress: Option<
|
||||
&(dyn Fn(u64, Option<f64>) -> BichonResult<()> + Send + Sync),
|
||||
>,
|
||||
) -> BichonResult<Vec<crate::cache::imap::download::gap_fill::RemoteHeader>> {
|
||||
progress: Option<&(dyn Fn(u64, Option<f64>) -> BichonResult<()> + Send + Sync)>,
|
||||
) -> BichonResult<Vec<crate::archive::imap::download::gap_fill::RemoteHeader>> {
|
||||
let mut stream = session
|
||||
.uid_fetch(uid_set, "(UID RFC822.SIZE INTERNALDATE BODY.PEEK[HEADER.FIELDS (MESSAGE-ID)])")
|
||||
.uid_fetch(
|
||||
uid_set,
|
||||
"(UID RFC822.SIZE INTERNALDATE BODY.PEEK[HEADER.FIELDS (MESSAGE-ID)])",
|
||||
)
|
||||
.await
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), classify_imap_error(&e)))?;
|
||||
|
||||
@@ -783,32 +950,28 @@ impl ImapExecutor {
|
||||
// seconds so the UI shows the wait climbing instead of a stale state.
|
||||
const STALL_REPORT_INTERVAL: std::time::Duration = std::time::Duration::from_secs(5);
|
||||
loop {
|
||||
let item =
|
||||
match tokio::time::timeout(STALL_REPORT_INTERVAL, stream.try_next()).await {
|
||||
Ok(Ok(item)) => Ok(item),
|
||||
Ok(Err(e)) => Err(raise_error!(
|
||||
format!("{:#?}", e),
|
||||
classify_imap_error(&e)
|
||||
)),
|
||||
Err(_) => {
|
||||
if token.is_cancelled() {
|
||||
return Err(raise_error!(
|
||||
"Stream cancelled".into(),
|
||||
ErrorCode::InternalError
|
||||
));
|
||||
}
|
||||
let stall_secs = last_recv.elapsed().as_secs_f64();
|
||||
if let Some(progress) = progress {
|
||||
progress(result.len() as u64, Some(stall_secs))?;
|
||||
}
|
||||
tracing::warn!(
|
||||
stall_secs = format!("{:.0}", stall_secs),
|
||||
fetched = result.len(),
|
||||
"fetch_uid_headers: server silent, waiting"
|
||||
);
|
||||
continue;
|
||||
let item = match tokio::time::timeout(STALL_REPORT_INTERVAL, stream.try_next()).await {
|
||||
Ok(Ok(item)) => Ok(item),
|
||||
Ok(Err(e)) => Err(raise_error!(format!("{:#?}", e), classify_imap_error(&e))),
|
||||
Err(_) => {
|
||||
if token.is_cancelled() {
|
||||
return Err(raise_error!(
|
||||
"Stream cancelled".into(),
|
||||
ErrorCode::InternalError
|
||||
));
|
||||
}
|
||||
};
|
||||
let stall_secs = last_recv.elapsed().as_secs_f64();
|
||||
if let Some(progress) = progress {
|
||||
progress(result.len() as u64, Some(stall_secs))?;
|
||||
}
|
||||
tracing::warn!(
|
||||
stall_secs = format!("{:.0}", stall_secs),
|
||||
fetched = result.len(),
|
||||
"fetch_uid_headers: server silent, waiting"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let item = match item {
|
||||
Ok(item) => item,
|
||||
Err(e) => return Err(e),
|
||||
@@ -832,7 +995,7 @@ impl ImapExecutor {
|
||||
None => &[],
|
||||
};
|
||||
let message_id = parse_message_id_header(header_bytes);
|
||||
result.push(crate::cache::imap::download::gap_fill::RemoteHeader {
|
||||
result.push(crate::archive::imap::download::gap_fill::RemoteHeader {
|
||||
uid,
|
||||
message_id,
|
||||
size,
|
||||
@@ -944,6 +1107,55 @@ pub fn generate_uid_sequence_hashset(
|
||||
result
|
||||
}
|
||||
|
||||
/// Filters `(uid, size, internal_date_epoch_millis)` entries to those whose
|
||||
/// internal date (ignoring time and timezone, matching RFC 3501's BEFORE key)
|
||||
/// is strictly earlier than `date` (`%d-%b-%Y`, e.g. "26-May-2025").
|
||||
/// Returns the matching UIDs sorted ascending. Errors if the date cannot be
|
||||
/// parsed — a silently-broken date filter would download mail the user asked
|
||||
/// to exclude.
|
||||
fn filter_before_date(entries: &[(u32, u64, i64)], date: &str) -> BichonResult<Vec<u32>> {
|
||||
let cutoff = chrono::NaiveDate::parse_from_str(date, "%d-%b-%Y").map_err(|e| {
|
||||
raise_error!(
|
||||
format!("Invalid BEFORE date '{date}': {e}"),
|
||||
ErrorCode::InvalidParameter
|
||||
)
|
||||
})?;
|
||||
let mut uid_vec: Vec<u32> = entries
|
||||
.iter()
|
||||
.filter(|&&(_, _, internal_date)| {
|
||||
let d = chrono::DateTime::from_timestamp_millis(internal_date)
|
||||
.map(|dt| dt.date_naive())
|
||||
.unwrap_or_default();
|
||||
d < cutoff
|
||||
})
|
||||
.map(|&(uid, _, _)| uid)
|
||||
.collect();
|
||||
uid_vec.sort();
|
||||
Ok(uid_vec)
|
||||
}
|
||||
|
||||
/// When a range enumeration comes back empty, decide whether that is an
|
||||
/// anomaly (server claims messages exist in the range but none were returned)
|
||||
/// or a genuine "no new mail" result. Returns a warning message for the
|
||||
/// anomaly, `None` when the empty result is legitimate (and highest_uid may be
|
||||
/// left unchanged safely).
|
||||
fn empty_enumeration_anomaly(
|
||||
mailbox_name: &str,
|
||||
uid_range: &str,
|
||||
start_uid: u64,
|
||||
server_uid_next: Option<u32>,
|
||||
) -> Option<String> {
|
||||
let uid_next = server_uid_next?;
|
||||
if (uid_next as u64) > start_uid {
|
||||
Some(format!(
|
||||
"Mailbox '{}': UID FETCH {} returned no UIDs but server UIDNEXT={} ({} messages in range). Refusing to advance highest_uid to avoid skipping them; the next sync will retry.",
|
||||
mailbox_name, uid_range, uid_next, uid_next.saturating_sub(start_uid as u32)
|
||||
))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_message_id_header(header_bytes: &[u8]) -> Option<String> {
|
||||
let header = std::str::from_utf8(header_bytes).ok()?;
|
||||
for line in header.lines() {
|
||||
@@ -968,6 +1180,8 @@ fn parse_message_id_header(header_bytes: &[u8]) -> Option<String> {
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use super::*;
|
||||
use crate::imap::session::SessionStream;
|
||||
use tokio_io_timeout::TimeoutStream;
|
||||
|
||||
// ── compress_uid_list ──────────────────────────────────────────
|
||||
|
||||
@@ -1021,6 +1235,43 @@ mod test {
|
||||
assert_eq!(batches[2].1, 1);
|
||||
}
|
||||
|
||||
// ── filter_before_date ─────────────────────────────────────────
|
||||
|
||||
fn ms(y: i32, m: u32, d: u32) -> i64 {
|
||||
chrono::NaiveDate::from_ymd_opt(y, m, d)
|
||||
.unwrap()
|
||||
.and_hms_opt(12, 0, 0)
|
||||
.unwrap()
|
||||
.and_utc()
|
||||
.timestamp_millis()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filter_before_date_keeps_only_earlier_dates() {
|
||||
let entries = vec![
|
||||
(1, 100, ms(2025, 5, 20)),
|
||||
(2, 200, ms(2025, 5, 26)), // exactly on the cutoff day — excluded (BEFORE is strict)
|
||||
(3, 300, ms(2025, 5, 27)),
|
||||
];
|
||||
let uids = filter_before_date(&entries, "27-May-2025").unwrap();
|
||||
assert_eq!(uids, vec![1, 2]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filter_before_date_timezone_ignored() {
|
||||
// Internal date late in the day in +1400 still counts as that day:
|
||||
// 2025-05-26 23:59:00 +1400 == 2025-05-26 09:59 UTC.
|
||||
let entries = vec![(1, 100, ms(2025, 5, 25)), (2, 200, ms(2025, 5, 26))];
|
||||
let uids = filter_before_date(&entries, "26-May-2025").unwrap();
|
||||
assert_eq!(uids, vec![1]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filter_before_date_invalid_date_errors() {
|
||||
let entries = vec![(1, 100, ms(2025, 5, 20))];
|
||||
assert!(filter_before_date(&entries, "not-a-date").is_err());
|
||||
}
|
||||
|
||||
// ── parse_message_id_header ─────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
@@ -1093,4 +1344,176 @@ To: recipient@example.com\r\n\r\n";
|
||||
Some("plain@example.com".into())
|
||||
);
|
||||
}
|
||||
|
||||
// ── empty_enumeration_anomaly ──────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn empty_enumeration_no_anomaly_when_uidnext_below_start() {
|
||||
// No new mail: server UIDNEXT <= start_uid → legitimate empty result.
|
||||
assert_eq!(
|
||||
empty_enumeration_anomaly("INBOX", "816098:*", 816098, Some(816098)),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
empty_enumeration_anomaly("INBOX", "816098:*", 816098, Some(816097)),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_enumeration_no_anomaly_when_uidnext_unknown() {
|
||||
// Server did not report UIDNEXT; cannot prove mail exists in range.
|
||||
assert_eq!(
|
||||
empty_enumeration_anomaly("INBOX", "816098:*", 816098, None),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_enumeration_anomaly_when_uidnext_above_start() {
|
||||
// Server claims messages exist but none came back → anomaly message.
|
||||
let msg = empty_enumeration_anomaly("portal_issues", "816098:*", 816098, Some(816118));
|
||||
let msg = msg.expect("should be Some for anomalous empty enumeration");
|
||||
assert!(msg.contains("portal_issues"));
|
||||
assert!(msg.contains("UIDNEXT=816118"));
|
||||
}
|
||||
|
||||
// ── collect_range_uids via mock server ─────────────────────────
|
||||
|
||||
use crate::imap::mock_server::{
|
||||
examine_response, uid_fetch_size_response, MockImapServer, MockImapServerHandle,
|
||||
};
|
||||
|
||||
/// Build an `async_imap::Session` connected to the mock server,
|
||||
/// authenticated and with the given mailbox examined.
|
||||
async fn mock_session(
|
||||
handle: &MockImapServerHandle,
|
||||
) -> async_imap::Session<Box<dyn SessionStream>> {
|
||||
let tcp = tokio::net::TcpStream::connect((handle.host(), handle.port()))
|
||||
.await
|
||||
.unwrap();
|
||||
let timeout_stream = TimeoutStream::new(tcp);
|
||||
let pinned: std::pin::Pin<Box<TimeoutStream<tokio::net::TcpStream>>> =
|
||||
Box::pin(timeout_stream);
|
||||
let stream: Box<dyn SessionStream> = Box::new(pinned);
|
||||
let mut client = async_imap::Client::new(stream);
|
||||
|
||||
// Read greeting
|
||||
client.read_response().await.unwrap();
|
||||
|
||||
// Login
|
||||
let mut session = client
|
||||
.login("user", "pass")
|
||||
.await
|
||||
.map_err(|(e, _)| panic!("Login failed: {e:?}"))
|
||||
.unwrap();
|
||||
|
||||
// Examine
|
||||
session.examine("INBOX").await.unwrap();
|
||||
|
||||
session
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn collect_range_uids_via_mock_server() {
|
||||
let handle = MockImapServer::new()
|
||||
.respond("LOGIN", "{TAG} OK LOGIN done\r\n")
|
||||
.respond("EXAMINE", examine_response("INBOX", 3, 42, 4))
|
||||
.respond(
|
||||
"UID FETCH",
|
||||
uid_fetch_size_response(&[(1, 100), (2, 200), (3, 300)]),
|
||||
)
|
||||
.start()
|
||||
.await;
|
||||
|
||||
let mut session = mock_session(&handle).await;
|
||||
|
||||
let mut mailbox = MailBox::default();
|
||||
mailbox.name = "INBOX".into();
|
||||
|
||||
let (entries, skipped) = ImapExecutor::collect_range_uids(
|
||||
&mut session,
|
||||
"1:*",
|
||||
1,
|
||||
&mailbox,
|
||||
None,
|
||||
CancellationToken::new(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
entries.iter().map(|&(uid, _, _)| uid).collect::<Vec<_>>(),
|
||||
vec![1, 2, 3]
|
||||
);
|
||||
assert_eq!(skipped, 0);
|
||||
session.logout().await.ok();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn collect_range_uids_empty_mailbox() {
|
||||
let handle = MockImapServer::new()
|
||||
.respond("LOGIN", "{TAG} OK LOGIN done\r\n")
|
||||
.respond("EXAMINE", examine_response("INBOX", 0, 42, 1))
|
||||
.respond("UID FETCH", b"{TAG} OK FETCH completed\r\n".to_vec())
|
||||
.start()
|
||||
.await;
|
||||
|
||||
let mut session = mock_session(&handle).await;
|
||||
|
||||
let mut mailbox = MailBox::default();
|
||||
mailbox.name = "INBOX".into();
|
||||
|
||||
let (entries, skipped) = ImapExecutor::collect_range_uids(
|
||||
&mut session,
|
||||
"1:*",
|
||||
1,
|
||||
&mailbox,
|
||||
None,
|
||||
CancellationToken::new(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(entries.is_empty());
|
||||
assert_eq!(skipped, 0);
|
||||
session.logout().await.ok();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn collect_range_uids_filters_oversized() {
|
||||
let handle = MockImapServer::new()
|
||||
.respond("LOGIN", "{TAG} OK LOGIN done\r\n")
|
||||
.respond("EXAMINE", examine_response("INBOX", 4, 42, 5))
|
||||
.respond(
|
||||
"UID FETCH",
|
||||
uid_fetch_size_response(&[(1, 100), (2, 500), (3, 1000), (4, 2000)]),
|
||||
)
|
||||
.start()
|
||||
.await;
|
||||
|
||||
let mut session = mock_session(&handle).await;
|
||||
|
||||
let mut mailbox = MailBox::default();
|
||||
mailbox.name = "INBOX".into();
|
||||
|
||||
// Limit 1000: UIDs 1..3 accepted, UID 4 (2000) skipped.
|
||||
let (entries, skipped) = ImapExecutor::collect_range_uids(
|
||||
&mut session,
|
||||
"1:*",
|
||||
1,
|
||||
&mailbox,
|
||||
Some(1000),
|
||||
CancellationToken::new(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
entries.iter().map(|&(uid, _, _)| uid).collect::<Vec<_>>(),
|
||||
vec![1, 2, 3]
|
||||
);
|
||||
assert_eq!(skipped, 1);
|
||||
session.logout().await.ok();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -255,6 +255,20 @@ pub fn uid_search_response(uids: &[u32]) -> Vec<u8> {
|
||||
format!("* SEARCH {uid_str}\r\n{{TAG}} OK SEARCH completed\r\n").into_bytes()
|
||||
}
|
||||
|
||||
/// Build a UID FETCH response returning UID + RFC822.SIZE + INTERNALDATE
|
||||
/// (no body). Each entry: (uid, size)
|
||||
pub fn uid_fetch_size_response(entries: &[(u32, u32)]) -> Vec<u8> {
|
||||
let mut out = Vec::new();
|
||||
for (uid, size) in entries {
|
||||
let line = format!(
|
||||
"* {uid} FETCH (UID {uid} RFC822.SIZE {size} INTERNALDATE \"01-Jan-2025 00:00:00 +0000\")\r\n"
|
||||
);
|
||||
out.extend_from_slice(line.as_bytes());
|
||||
}
|
||||
out.extend_from_slice(b"{TAG} OK FETCH completed\r\n");
|
||||
out
|
||||
}
|
||||
|
||||
/// Build a UID FETCH response returning full headers (for BODY[HEADER]).
|
||||
/// Each entry: (uid, message_id)
|
||||
pub fn uid_fetch_metadata_response(entries: &[(u32, &str)]) -> Vec<u8> {
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
//
|
||||
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
|
||||
//
|
||||
// This file is part of the Bichon Email Archiving Project
|
||||
@@ -16,45 +15,40 @@
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
//use poem_openapi::Object;
|
||||
// use poem_openapi::Object;
|
||||
pub mod history;
|
||||
pub mod reader;
|
||||
pub mod pst;
|
||||
pub mod reader;
|
||||
use std::{collections::HashMap, path::Path, sync::RwLock};
|
||||
|
||||
pub use history::ImportHistory;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
path::Path,
|
||||
sync::RwLock,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
account::migration::{AccountModel, AccountType},
|
||||
base64_decode_url_safe,
|
||||
{
|
||||
account::migration::{AccountModel, AccountType},
|
||||
cache::imap::mailbox::{Attribute, AttributeEnum, MailBox},
|
||||
envelope::extractor::extract_envelope_from_eml,
|
||||
error::{BichonResult, code::ErrorCode},
|
||||
settings::dir::DATA_DIR_MANAGER,
|
||||
utils::create_hash,
|
||||
},
|
||||
archive::imap::mailbox::{Attribute, AttributeEnum, MailBox},
|
||||
envelope::extractor::{extract_envelope_from_eml, ExtractOutcome},
|
||||
error::{code::ErrorCode, BichonResult},
|
||||
raise_error,
|
||||
settings::dir::DATA_DIR_MANAGER,
|
||||
utils::create_hash,
|
||||
};
|
||||
|
||||
/// Maximum byte size of an individual email message after splitting (100 MB).
|
||||
const MAX_SINGLE_EML_BYTES: usize = 100 * 1024 * 1024;
|
||||
|
||||
/// Max file size accepted via the web upload endpoint.
|
||||
pub const MAX_WEB_EML_BYTES: usize = 100 * 1024 * 1024; // 100 MB
|
||||
pub const MAX_WEB_MBOX_BYTES: usize = 1024 * 1024 * 1024; // 1 GB
|
||||
pub const MAX_WEB_EML_BYTES: usize = 100 * 1024 * 1024; // 100 MB
|
||||
pub const MAX_WEB_MBOX_BYTES: usize = 1024 * 1024 * 1024; // 1 GB
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[cfg_attr(feature = "web-api", derive(poem_openapi::Object))]
|
||||
pub struct BatchEmlRequest {
|
||||
pub account_id: u64,
|
||||
pub mail_folder: String,
|
||||
/// A list of emails in base64-encoded format. Each element represents one .eml file.
|
||||
/// A list of emails in base64-encoded format. Each element represents one
|
||||
/// .eml file.
|
||||
pub emls: Vec<String>,
|
||||
}
|
||||
|
||||
@@ -87,26 +81,31 @@ pub struct ImportEmls;
|
||||
impl ImportEmls {
|
||||
pub async fn do_import(mut request: BatchEmlRequest) -> BichonResult<BatchEmlResult> {
|
||||
let account = AccountModel::check_account_exists(request.account_id)?;
|
||||
|
||||
|
||||
if !account.enabled {
|
||||
return Err(raise_error!("The account is disabled and cannot be used for this operation.".into(), ErrorCode::InvalidParameter));
|
||||
return Err(raise_error!(
|
||||
"The account is disabled and cannot be used for this operation.".into(),
|
||||
ErrorCode::InvalidParameter
|
||||
));
|
||||
}
|
||||
|
||||
let mailbox_id = match account.account_type {
|
||||
AccountType::IMAP => {
|
||||
let all_mailboxes = MailBox::list_all(account.id)?;
|
||||
let mailbox = all_mailboxes.into_iter().find(|m| m.name == request.mail_folder);
|
||||
|
||||
let mailbox = all_mailboxes
|
||||
.into_iter()
|
||||
.find(|m| m.name == request.mail_folder);
|
||||
|
||||
match mailbox {
|
||||
Some(mailbox) => mailbox.id,
|
||||
None => return Err(raise_error!(
|
||||
format!("Mail folder '{}' not found for account ID {}. The target folder must exist before importing.",
|
||||
request.mail_folder,
|
||||
format!("Mail folder '{}' not found for account ID {}. The target folder must exist before importing.",
|
||||
request.mail_folder,
|
||||
request.account_id).into(),
|
||||
ErrorCode::ResourceNotFound
|
||||
)),
|
||||
}
|
||||
},
|
||||
}
|
||||
AccountType::NoSync => {
|
||||
let mailbox = MailBox {
|
||||
id: create_hash(request.account_id, &request.mail_folder),
|
||||
@@ -127,11 +126,12 @@ impl ImportEmls {
|
||||
// Upsert the mailbox, creating it if it doesn't exist
|
||||
MailBox::batch_upsert(&[mailbox])?;
|
||||
mailbox_id
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
let account_id = account.id;
|
||||
let mut success_count = 0;
|
||||
let mut duplicate_count = 0;
|
||||
let mut failed_details: Vec<FailedItemDetail> = Vec::new(); // Store failure details
|
||||
|
||||
let total = request.emls.len();
|
||||
@@ -169,9 +169,12 @@ impl ImportEmls {
|
||||
}
|
||||
|
||||
match extract_envelope_from_eml(&decoded, account_id, mailbox_id).await {
|
||||
Ok(_) => {
|
||||
Ok(ExtractOutcome::Imported) => {
|
||||
success_count += 1;
|
||||
},
|
||||
}
|
||||
Ok(ExtractOutcome::Duplicate) => {
|
||||
duplicate_count += 1;
|
||||
}
|
||||
Err(e) => {
|
||||
let error_msg = format!(
|
||||
"Failed to extract envelope from EML at index {}: {:?}",
|
||||
@@ -194,7 +197,7 @@ impl ImportEmls {
|
||||
Ok(BatchEmlResult {
|
||||
total,
|
||||
success: success_count,
|
||||
duplicates: 0,
|
||||
duplicates: duplicate_count,
|
||||
failed: failed_count,
|
||||
failed_details, // Return the list of failure details
|
||||
})
|
||||
@@ -279,8 +282,8 @@ pub fn detect_format(bytes: &[u8], file_name: &str) -> Option<FileFormat> {
|
||||
}
|
||||
}
|
||||
}
|
||||
// EML: starts with a header line or "Return-Path:", "Received:", "From:", "Date:", etc.
|
||||
// Or check extension
|
||||
// EML: starts with a header line or "Return-Path:", "Received:", "From:",
|
||||
// "Date:", etc. Or check extension
|
||||
if bytes.starts_with(b"Return-Path:")
|
||||
|| bytes.starts_with(b"Received:")
|
||||
|| bytes.starts_with(b"Date:")
|
||||
@@ -305,11 +308,12 @@ pub fn detect_format(bytes: &[u8], file_name: &str) -> Option<FileFormat> {
|
||||
}
|
||||
|
||||
/// Check whether `bytes` looks like a text file by inspecting the first chunk.
|
||||
/// Returns `true` if it passes, `false` if it appears to be binary (video, executable, etc.).
|
||||
/// Returns `true` if it passes, `false` if it appears to be binary (video,
|
||||
/// executable, etc.).
|
||||
///
|
||||
/// Email files (EML/MBOX) are text-based with printable ASCII, whitespace, and
|
||||
/// optional UTF-8. Binary files like video contain null bytes and high ratios of
|
||||
/// non-printable control characters.
|
||||
/// optional UTF-8. Binary files like video contain null bytes and high ratios
|
||||
/// of non-printable control characters.
|
||||
pub fn detect_text_file(bytes: &[u8]) -> bool {
|
||||
let check_len = bytes.len().min(8192);
|
||||
if check_len == 0 {
|
||||
@@ -356,7 +360,8 @@ pub fn detect_text_file(bytes: &[u8]) -> bool {
|
||||
}
|
||||
// standalone continuation byte — not printable
|
||||
}
|
||||
// Other control characters (0x01-0x1F except whitespace/Esc) are not counted as printable
|
||||
// Other control characters (0x01-0x1F except whitespace/Esc) are not counted as
|
||||
// printable
|
||||
|
||||
i += 1;
|
||||
}
|
||||
@@ -376,7 +381,8 @@ fn validate_import_account(account_id: u64) -> BichonResult<AccountModel> {
|
||||
}
|
||||
if !matches!(account.account_type, AccountType::NoSync) {
|
||||
return Err(raise_error!(
|
||||
"Import is only allowed for NoSync accounts. IMAP accounts sync from the server.".into(),
|
||||
"Import is only allowed for NoSync accounts. IMAP accounts sync from the server."
|
||||
.into(),
|
||||
ErrorCode::InvalidParameter
|
||||
));
|
||||
}
|
||||
@@ -428,12 +434,13 @@ pub fn resolve_mailbox_by_account_id(account_id: u64, folder: &str) -> BichonRes
|
||||
resolve_mailbox(&account, folder)
|
||||
}
|
||||
|
||||
/// Process an uploaded file (EML or MBOX) and import into the given account/folder.
|
||||
/// This runs synchronously and should be spawned on a background thread.
|
||||
/// Process an uploaded file (EML or MBOX) and import into the given
|
||||
/// account/folder. This runs synchronously and should be spawned on a
|
||||
/// background thread.
|
||||
///
|
||||
/// For MBOX files, the file is memory-mapped via `memmap2` and messages are yielded
|
||||
/// one at a time — the full file is never loaded into RAM. Individual messages
|
||||
/// exceeding `MAX_SINGLE_EML_BYTES` (100 MB) are skipped.
|
||||
/// For MBOX files, the file is memory-mapped via `memmap2` and messages are
|
||||
/// yielded one at a time — the full file is never loaded into RAM. Individual
|
||||
/// messages exceeding `MAX_SINGLE_EML_BYTES` (100 MB) are skipped.
|
||||
pub fn process_uploaded_file(
|
||||
import_id: &str,
|
||||
file_path: &Path,
|
||||
@@ -511,9 +518,15 @@ pub fn process_uploaded_file(
|
||||
};
|
||||
|
||||
match format {
|
||||
FileFormat::Eml => process_eml_file(import_id, file_path, account_id, mailbox_id, user_id, folder),
|
||||
FileFormat::Mbox => process_mbox_file(import_id, file_path, account_id, mailbox_id, user_id, folder),
|
||||
FileFormat::Pst => process_pst_upload(import_id, file_path, account_id, mailbox_id, user_id, folder),
|
||||
FileFormat::Eml => process_eml_file(
|
||||
import_id, file_path, account_id, mailbox_id, user_id, folder,
|
||||
),
|
||||
FileFormat::Mbox => process_mbox_file(
|
||||
import_id, file_path, account_id, mailbox_id, user_id, folder,
|
||||
),
|
||||
FileFormat::Pst => process_pst_upload(
|
||||
import_id, file_path, account_id, mailbox_id, user_id, folder,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -521,7 +534,10 @@ pub fn process_uploaded_file(
|
||||
fn detect_format_from_file(file_path: &Path, file_name: &str) -> BichonResult<FileFormat> {
|
||||
use std::io::Read;
|
||||
let mut file = std::fs::File::open(file_path).map_err(|e| {
|
||||
raise_error!(format!("Failed to open file: {}", e), ErrorCode::InternalError)
|
||||
raise_error!(
|
||||
format!("Failed to open file: {}", e),
|
||||
ErrorCode::InternalError
|
||||
)
|
||||
})?;
|
||||
let mut buf = vec![0u8; 8192];
|
||||
let n = file.read(&mut buf).unwrap_or(0);
|
||||
@@ -548,25 +564,36 @@ fn process_eml_file(
|
||||
let file_bytes = match std::fs::read(file_path) {
|
||||
Ok(b) => b,
|
||||
Err(e) => {
|
||||
fail_progress(import_id, "eml", &format!("Failed to read file: {}", e), user_id, account_id, folder);
|
||||
fail_progress(
|
||||
import_id,
|
||||
"eml",
|
||||
&format!("Failed to read file: {}", e),
|
||||
user_id,
|
||||
account_id,
|
||||
folder,
|
||||
);
|
||||
let _ = std::fs::remove_file(file_path);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let total = 1;
|
||||
update_progress(import_id, ImportProgress {
|
||||
import_id: import_id.to_string(),
|
||||
status: ImportStatus::Processing,
|
||||
format: "eml".to_string(),
|
||||
total,
|
||||
success: 0,
|
||||
duplicates: 0,
|
||||
failed: 0,
|
||||
failed_details: vec![],
|
||||
});
|
||||
update_progress(
|
||||
import_id,
|
||||
ImportProgress {
|
||||
import_id: import_id.to_string(),
|
||||
status: ImportStatus::Processing,
|
||||
format: "eml".to_string(),
|
||||
total,
|
||||
success: 0,
|
||||
duplicates: 0,
|
||||
failed: 0,
|
||||
failed_details: vec![],
|
||||
},
|
||||
);
|
||||
|
||||
let (success_count, failed_details) = process_single_eml(&file_bytes, 0, account_id, mailbox_id);
|
||||
let (success_count, duplicate_count, failed_details) =
|
||||
process_single_eml(&file_bytes, 0, account_id, mailbox_id);
|
||||
|
||||
// Clean up
|
||||
let _ = std::fs::remove_file(file_path);
|
||||
@@ -577,7 +604,7 @@ fn process_eml_file(
|
||||
format: "eml".to_string(),
|
||||
total,
|
||||
success: success_count,
|
||||
duplicates: 0,
|
||||
duplicates: duplicate_count,
|
||||
failed: failed_details.len(),
|
||||
failed_details,
|
||||
};
|
||||
@@ -598,27 +625,39 @@ fn process_mbox_file(
|
||||
let mbox = match reader::MboxFile::from_file(file_path) {
|
||||
Ok(m) => m,
|
||||
Err(e) => {
|
||||
fail_progress(import_id, "mbox", &format!("Failed to open MBOX file: {}", e), user_id, account_id, folder);
|
||||
fail_progress(
|
||||
import_id,
|
||||
"mbox",
|
||||
&format!("Failed to open MBOX file: {}", e),
|
||||
user_id,
|
||||
account_id,
|
||||
folder,
|
||||
);
|
||||
let _ = std::fs::remove_file(file_path);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// First pass: count total messages (MboxReader is lazy, so this is O(n) but cheap)
|
||||
// First pass: count total messages (MboxReader is lazy, so this is O(n) but
|
||||
// cheap)
|
||||
let total = mbox.iter().count();
|
||||
|
||||
update_progress(import_id, ImportProgress {
|
||||
import_id: import_id.to_string(),
|
||||
status: ImportStatus::Processing,
|
||||
format: "mbox".to_string(),
|
||||
total,
|
||||
success: 0,
|
||||
duplicates: 0,
|
||||
failed: 0,
|
||||
failed_details: vec![],
|
||||
});
|
||||
update_progress(
|
||||
import_id,
|
||||
ImportProgress {
|
||||
import_id: import_id.to_string(),
|
||||
status: ImportStatus::Processing,
|
||||
format: "mbox".to_string(),
|
||||
total,
|
||||
success: 0,
|
||||
duplicates: 0,
|
||||
failed: 0,
|
||||
failed_details: vec![],
|
||||
},
|
||||
);
|
||||
|
||||
let mut success_count = 0usize;
|
||||
let mut duplicate_count = 0usize;
|
||||
let mut failed_details: Vec<FailedItemDetail> = Vec::new();
|
||||
|
||||
for (index, entry) in mbox.iter().enumerate() {
|
||||
@@ -638,10 +677,15 @@ fn process_mbox_file(
|
||||
continue;
|
||||
}
|
||||
|
||||
match futures::executor::block_on(extract_envelope_from_eml(eml_bytes, account_id, mailbox_id)) {
|
||||
Ok(_) => {
|
||||
match futures::executor::block_on(extract_envelope_from_eml(
|
||||
eml_bytes, account_id, mailbox_id,
|
||||
)) {
|
||||
Ok(ExtractOutcome::Imported) => {
|
||||
success_count += 1;
|
||||
}
|
||||
Ok(ExtractOutcome::Duplicate) => {
|
||||
duplicate_count += 1;
|
||||
}
|
||||
Err(e) => {
|
||||
failed_details.push(FailedItemDetail {
|
||||
index,
|
||||
@@ -652,16 +696,19 @@ fn process_mbox_file(
|
||||
|
||||
// Update progress every 100 items
|
||||
if index % 100 == 0 || index == total - 1 {
|
||||
update_progress(import_id, ImportProgress {
|
||||
import_id: import_id.to_string(),
|
||||
status: ImportStatus::Processing,
|
||||
format: "mbox".to_string(),
|
||||
total,
|
||||
success: success_count,
|
||||
duplicates: 0,
|
||||
failed: failed_details.len(),
|
||||
failed_details: failed_details.clone(),
|
||||
});
|
||||
update_progress(
|
||||
import_id,
|
||||
ImportProgress {
|
||||
import_id: import_id.to_string(),
|
||||
status: ImportStatus::Processing,
|
||||
format: "mbox".to_string(),
|
||||
total,
|
||||
success: success_count,
|
||||
duplicates: duplicate_count,
|
||||
failed: failed_details.len(),
|
||||
failed_details: failed_details.clone(),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -675,7 +722,7 @@ fn process_mbox_file(
|
||||
format: "mbox".to_string(),
|
||||
total,
|
||||
success: success_count,
|
||||
duplicates: 0,
|
||||
duplicates: duplicate_count,
|
||||
failed: failed_details.len(),
|
||||
failed_details,
|
||||
};
|
||||
@@ -683,41 +730,53 @@ fn process_mbox_file(
|
||||
update_progress(import_id, final_progress);
|
||||
}
|
||||
|
||||
/// Process a single EML byte slice and return (success_count, failed_details).
|
||||
/// Process a single EML byte slice and return (success_count, duplicate_count,
|
||||
/// failed_details).
|
||||
fn process_single_eml(
|
||||
eml_bytes: &[u8],
|
||||
index: usize,
|
||||
account_id: u64,
|
||||
mailbox_id: u64,
|
||||
) -> (usize, Vec<FailedItemDetail>) {
|
||||
) -> (usize, usize, Vec<FailedItemDetail>) {
|
||||
if eml_bytes.len() > MAX_SINGLE_EML_BYTES {
|
||||
let size_mb = eml_bytes.len() as f64 / 1024.0 / 1024.0;
|
||||
return (0, vec![FailedItemDetail {
|
||||
index,
|
||||
error_message: format!(
|
||||
"Email is {:.1} MB (limit {} MB). Skipping.",
|
||||
size_mb,
|
||||
MAX_SINGLE_EML_BYTES / 1024 / 1024
|
||||
),
|
||||
}]);
|
||||
return (
|
||||
0,
|
||||
0,
|
||||
vec![FailedItemDetail {
|
||||
index,
|
||||
error_message: format!(
|
||||
"Email is {:.1} MB (limit {} MB). Skipping.",
|
||||
size_mb,
|
||||
MAX_SINGLE_EML_BYTES / 1024 / 1024
|
||||
),
|
||||
}],
|
||||
);
|
||||
}
|
||||
|
||||
match futures::executor::block_on(extract_envelope_from_eml(eml_bytes, account_id, mailbox_id)) {
|
||||
Ok(_) => (1, vec![]),
|
||||
Err(e) => (0, vec![FailedItemDetail {
|
||||
index,
|
||||
error_message: format!("{:?}", e),
|
||||
}]),
|
||||
match futures::executor::block_on(extract_envelope_from_eml(eml_bytes, account_id, mailbox_id))
|
||||
{
|
||||
Ok(ExtractOutcome::Imported) => (1, 0, vec![]),
|
||||
Ok(ExtractOutcome::Duplicate) => (0, 1, vec![]),
|
||||
Err(e) => (
|
||||
0,
|
||||
0,
|
||||
vec![FailedItemDetail {
|
||||
index,
|
||||
error_message: format!("{:?}", e),
|
||||
}],
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// Process a PST file uploaded via the web UI.
|
||||
/// Two-pass approach: count messages first, then process with periodic progress updates.
|
||||
/// Two-pass approach: count messages first, then process with periodic progress
|
||||
/// updates.
|
||||
fn process_pst_upload(
|
||||
import_id: &str,
|
||||
file_path: &Path,
|
||||
account_id: u64,
|
||||
_mailbox_id: u64, // ignored; PST creates its own mailboxes per folder
|
||||
_mailbox_id: u64, // ignored; PST creates its own mailboxes per folder
|
||||
user_id: u64,
|
||||
folder: &str,
|
||||
) {
|
||||
@@ -725,32 +784,50 @@ fn process_pst_upload(
|
||||
let total = match pst::count_pst_messages(file_path) {
|
||||
Ok(n) => n,
|
||||
Err(e) => {
|
||||
fail_progress(import_id, "pst", &format!("{:?}", e), user_id, account_id, folder);
|
||||
fail_progress(
|
||||
import_id,
|
||||
"pst",
|
||||
&format!("{:?}", e),
|
||||
user_id,
|
||||
account_id,
|
||||
folder,
|
||||
);
|
||||
let _ = std::fs::remove_file(file_path);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
update_progress(import_id, ImportProgress {
|
||||
import_id: import_id.to_string(),
|
||||
status: ImportStatus::Processing,
|
||||
format: "pst".to_string(),
|
||||
total,
|
||||
success: 0,
|
||||
duplicates: 0,
|
||||
failed: 0,
|
||||
failed_details: vec![],
|
||||
});
|
||||
update_progress(
|
||||
import_id,
|
||||
ImportProgress {
|
||||
import_id: import_id.to_string(),
|
||||
status: ImportStatus::Processing,
|
||||
format: "pst".to_string(),
|
||||
total,
|
||||
success: 0,
|
||||
duplicates: 0,
|
||||
failed: 0,
|
||||
failed_details: vec![],
|
||||
},
|
||||
);
|
||||
|
||||
// Pass 2: process messages with progress updates
|
||||
let mut success_count: usize = 0;
|
||||
let mut duplicate_count: usize = 0;
|
||||
let mut failed_details: Vec<FailedItemDetail> = Vec::new();
|
||||
let mut index: usize = 0;
|
||||
|
||||
let pst_store = match outlook_pst::open_store(file_path) {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
fail_progress(import_id, "pst", &format!("{:?}", e), user_id, account_id, folder);
|
||||
fail_progress(
|
||||
import_id,
|
||||
"pst",
|
||||
&format!("{:?}", e),
|
||||
user_id,
|
||||
account_id,
|
||||
folder,
|
||||
);
|
||||
let _ = std::fs::remove_file(file_path);
|
||||
return;
|
||||
}
|
||||
@@ -759,7 +836,14 @@ fn process_pst_upload(
|
||||
let ipm_sub_tree = match pst_store.properties().ipm_sub_tree_entry_id() {
|
||||
Ok(id) => id,
|
||||
Err(e) => {
|
||||
fail_progress(import_id, "pst", &format!("{:?}", e), user_id, account_id, folder);
|
||||
fail_progress(
|
||||
import_id,
|
||||
"pst",
|
||||
&format!("{:?}", e),
|
||||
user_id,
|
||||
account_id,
|
||||
folder,
|
||||
);
|
||||
let _ = std::fs::remove_file(file_path);
|
||||
return;
|
||||
}
|
||||
@@ -768,7 +852,14 @@ fn process_pst_upload(
|
||||
let ipm_subtree_folder = match pst_store.open_folder(&ipm_sub_tree) {
|
||||
Ok(f) => f,
|
||||
Err(e) => {
|
||||
fail_progress(import_id, "pst", &format!("{:?}", e), user_id, account_id, folder);
|
||||
fail_progress(
|
||||
import_id,
|
||||
"pst",
|
||||
&format!("{:?}", e),
|
||||
user_id,
|
||||
account_id,
|
||||
folder,
|
||||
);
|
||||
let _ = std::fs::remove_file(file_path);
|
||||
return;
|
||||
}
|
||||
@@ -779,23 +870,27 @@ fn process_pst_upload(
|
||||
let format_str = "pst".to_string();
|
||||
pst::process_folder_with_progress(
|
||||
&ipm_subtree_folder,
|
||||
"", // parent_path starts empty
|
||||
"", // parent_path starts empty
|
||||
account_id,
|
||||
total, // pass pre-counted total for accurate progress
|
||||
total, // pass pre-counted total for accurate progress
|
||||
&mut success_count,
|
||||
&mut duplicate_count,
|
||||
&mut failed_details,
|
||||
&mut index,
|
||||
&|processed, actual_failed| {
|
||||
update_progress(&import_id, ImportProgress {
|
||||
import_id: import_id.clone(),
|
||||
status: ImportStatus::Processing,
|
||||
format: format_str.clone(),
|
||||
total,
|
||||
success: processed - actual_failed,
|
||||
duplicates: 0,
|
||||
failed: actual_failed,
|
||||
failed_details: vec![],
|
||||
});
|
||||
&|success, duplicates, actual_failed| {
|
||||
update_progress(
|
||||
&import_id,
|
||||
ImportProgress {
|
||||
import_id: import_id.clone(),
|
||||
status: ImportStatus::Processing,
|
||||
format: format_str.clone(),
|
||||
total,
|
||||
success,
|
||||
duplicates,
|
||||
failed: actual_failed,
|
||||
failed_details: vec![],
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -808,7 +903,7 @@ fn process_pst_upload(
|
||||
format: "pst".to_string(),
|
||||
total,
|
||||
success: success_count,
|
||||
duplicates: 0,
|
||||
duplicates: duplicate_count,
|
||||
failed: failed_details.len(),
|
||||
failed_details,
|
||||
};
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
//
|
||||
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
|
||||
//
|
||||
// This file is part of the Bichon Email Archiving Project
|
||||
@@ -16,18 +15,25 @@
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
use crate::base64_encode_url_safe;
|
||||
use crate::envelope::extractor::extract_envelope_from_eml;
|
||||
use chrono::{DateTime, TimeZone, Utc};
|
||||
use mail_send::mail_builder::headers::text::Text;
|
||||
use mail_send::mail_builder::MessageBuilder;
|
||||
use outlook_pst::ltp::prop_context::PropertyValue;
|
||||
use outlook_pst::messaging::attachment::AttachmentProperties;
|
||||
use outlook_pst::messaging::folder::Folder;
|
||||
use outlook_pst::messaging::message::{Message, MessageProperties};
|
||||
use outlook_pst::ndb::node_id::NodeId;
|
||||
use std::rc::Rc;
|
||||
|
||||
use chrono::{DateTime, TimeZone, Utc};
|
||||
use mail_send::mail_builder::{headers::text::Text, MessageBuilder};
|
||||
use outlook_pst::{
|
||||
ltp::prop_context::PropertyValue,
|
||||
messaging::{
|
||||
attachment::AttachmentProperties,
|
||||
folder::Folder,
|
||||
message::{Message, MessageProperties},
|
||||
},
|
||||
ndb::node_id::NodeId,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
base64_encode_url_safe,
|
||||
envelope::extractor::{extract_envelope_from_eml, ExtractOutcome},
|
||||
};
|
||||
|
||||
mod encoding;
|
||||
|
||||
/// Convert a PST Message into a base64-encoded EML string.
|
||||
@@ -193,7 +199,9 @@ fn extract_recipients_list(message: &Rc<dyn Message>) -> (Vec<String>, Vec<Strin
|
||||
}
|
||||
|
||||
fn extract_subject(props: &MessageProperties) -> Option<String> {
|
||||
props.get(0x0037).and_then(|val| encoding::decode_subject(val))
|
||||
props
|
||||
.get(0x0037)
|
||||
.and_then(|val| encoding::decode_subject(val))
|
||||
}
|
||||
|
||||
fn extract_string_property(properties: &MessageProperties, prop_id: u16) -> Option<String> {
|
||||
@@ -268,12 +276,15 @@ pub fn count_pst_messages(pst_path: &std::path::Path) -> crate::error::BichonRes
|
||||
)
|
||||
})?;
|
||||
|
||||
let ipm_sub_tree = pst_store.properties().ipm_sub_tree_entry_id().map_err(|e| {
|
||||
crate::raise_error!(
|
||||
format!("Could not find IPM_SUBTREE in PST: {:?}", e),
|
||||
crate::error::code::ErrorCode::InvalidParameter
|
||||
)
|
||||
})?;
|
||||
let ipm_sub_tree = pst_store
|
||||
.properties()
|
||||
.ipm_sub_tree_entry_id()
|
||||
.map_err(|e| {
|
||||
crate::raise_error!(
|
||||
format!("Could not find IPM_SUBTREE in PST: {:?}", e),
|
||||
crate::error::code::ErrorCode::InvalidParameter
|
||||
)
|
||||
})?;
|
||||
|
||||
let ipm_subtree_folder = pst_store.open_folder(&ipm_sub_tree).map_err(|e| {
|
||||
crate::raise_error!(
|
||||
@@ -327,11 +338,12 @@ pub fn process_folder_with_progress<F>(
|
||||
account_id: u64,
|
||||
total: usize,
|
||||
success_count: &mut usize,
|
||||
duplicate_count: &mut usize,
|
||||
failed_details: &mut Vec<super::FailedItemDetail>,
|
||||
index: &mut usize,
|
||||
progress_cb: &F,
|
||||
) where
|
||||
F: Fn(usize, usize), // (processed, failed)
|
||||
F: Fn(usize, usize, usize), // (success, duplicates, failed)
|
||||
{
|
||||
process_folder_with_progress_inner(
|
||||
folder,
|
||||
@@ -339,6 +351,7 @@ pub fn process_folder_with_progress<F>(
|
||||
account_id,
|
||||
total,
|
||||
success_count,
|
||||
duplicate_count,
|
||||
failed_details,
|
||||
index,
|
||||
progress_cb,
|
||||
@@ -351,11 +364,12 @@ fn process_folder_with_progress_inner<F>(
|
||||
account_id: u64,
|
||||
total: usize,
|
||||
success_count: &mut usize,
|
||||
duplicate_count: &mut usize,
|
||||
failed_details: &mut Vec<super::FailedItemDetail>,
|
||||
index: &mut usize,
|
||||
progress_cb: &F,
|
||||
) where
|
||||
F: Fn(usize, usize),
|
||||
F: Fn(usize, usize, usize),
|
||||
{
|
||||
let folder_name = folder
|
||||
.properties()
|
||||
@@ -386,6 +400,7 @@ fn process_folder_with_progress_inner<F>(
|
||||
account_id,
|
||||
total,
|
||||
success_count,
|
||||
duplicate_count,
|
||||
failed_details,
|
||||
index,
|
||||
progress_cb,
|
||||
@@ -434,12 +449,15 @@ fn process_folder_with_progress_inner<F>(
|
||||
}
|
||||
};
|
||||
|
||||
match futures::executor::block_on(
|
||||
extract_envelope_from_eml(&decoded, account_id, mailbox_id)
|
||||
) {
|
||||
Ok(_) => {
|
||||
match futures::executor::block_on(extract_envelope_from_eml(
|
||||
&decoded, account_id, mailbox_id,
|
||||
)) {
|
||||
Ok(ExtractOutcome::Imported) => {
|
||||
*success_count += 1;
|
||||
}
|
||||
Ok(ExtractOutcome::Duplicate) => {
|
||||
*duplicate_count += 1;
|
||||
}
|
||||
Err(e) => {
|
||||
failed_details.push(super::FailedItemDetail {
|
||||
index: *index,
|
||||
@@ -459,7 +477,7 @@ fn process_folder_with_progress_inner<F>(
|
||||
|
||||
// Report progress every 50 messages
|
||||
if batch_size % 50 == 0 {
|
||||
progress_cb(*success_count + failed_details.len(), failed_details.len());
|
||||
progress_cb(*success_count, *duplicate_count, failed_details.len());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -475,6 +493,7 @@ fn process_folder_with_progress_inner<F>(
|
||||
account_id,
|
||||
total,
|
||||
success_count,
|
||||
duplicate_count,
|
||||
failed_details,
|
||||
index,
|
||||
progress_cb,
|
||||
|
||||
@@ -2,7 +2,7 @@ pub mod account;
|
||||
pub mod ext;
|
||||
pub mod admin;
|
||||
pub mod autoconfig;
|
||||
pub mod cache;
|
||||
pub mod archive;
|
||||
pub mod common;
|
||||
pub mod context;
|
||||
pub mod dashboard;
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
use crate::{
|
||||
cache::imap::mailbox::MailBox,
|
||||
archive::imap::mailbox::MailBox,
|
||||
error::BichonResult,
|
||||
store::tantivy::{attachment::ATTACHMENT_MANAGER, envelope::ENVELOPE_MANAGER},
|
||||
};
|
||||
|
||||
@@ -17,8 +17,8 @@
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
use crate::account::migration::{AccountModel, AccountType};
|
||||
use crate::cache::imap::mailbox::{Attribute, AttributeEnum, MailBox};
|
||||
use crate::cache::imap::mailbox_cache::{self, FetchStatus};
|
||||
use crate::archive::imap::mailbox::{Attribute, AttributeEnum, MailBox};
|
||||
use crate::archive::imap::mailbox_cache::{self, FetchStatus};
|
||||
use crate::error::code::ErrorCode;
|
||||
use crate::error::BichonResult;
|
||||
use crate::imap::executor::ImapExecutor;
|
||||
|
||||
@@ -169,6 +169,10 @@ pub struct AttachmentSearchRequest {
|
||||
desc: Option<bool>,
|
||||
}
|
||||
impl AttachmentSearchRequest {
|
||||
pub fn filter(&self) -> &AttachmentSearchFilter {
|
||||
&self.filter
|
||||
}
|
||||
|
||||
pub fn validate(&self) -> BichonResult<()> {
|
||||
if self.page == 0 || self.page_size == 0 {
|
||||
return Err(raise_error!(
|
||||
|
||||
@@ -402,6 +402,17 @@ pub struct Settings {
|
||||
help = "Maximum per-file size in MB for PST uploads via the web UI"
|
||||
)]
|
||||
pub bichon_web_pst_upload_limit_mb: u64,
|
||||
|
||||
/// Audit log retention period in days (default: 90). Older audit records
|
||||
/// are purged periodically by a background task. 0 disables the cleanup.
|
||||
/// Pro edition only.
|
||||
#[clap(
|
||||
long,
|
||||
default_value = "90",
|
||||
env,
|
||||
help = "Audit log retention period in days (0 disables cleanup). Pro edition only."
|
||||
)]
|
||||
pub bichon_audit_retention_days: u64,
|
||||
}
|
||||
|
||||
impl Settings {
|
||||
|
||||
@@ -28,7 +28,7 @@ use crate::{
|
||||
raise_error,
|
||||
{
|
||||
account::migration::AccountModel,
|
||||
cache::imap::mailbox::MailBox,
|
||||
archive::imap::mailbox::MailBox,
|
||||
error::{code::ErrorCode, BichonResult},
|
||||
message::content::AttachmentInfo,
|
||||
store::{
|
||||
|
||||
@@ -25,7 +25,7 @@ use std::sync::LazyLock;
|
||||
|
||||
use bichon_core::{
|
||||
bichon_version,
|
||||
cache::imap::task::SYNC_TASKS,
|
||||
archive::imap::task::SYNC_TASKS,
|
||||
common::{rustls::BichonTls, signal::SignalManager},
|
||||
context::{executors::BichonContext, Initialize},
|
||||
database::manager::DB_MANAGER,
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
use crate::common::auth::WrappedContext;
|
||||
use crate::rest::api::ApiTags;
|
||||
use crate::rest::ApiResult;
|
||||
use bichon_core::ext::event_bus::{emit, Event};
|
||||
use bichon_core::token::view::AccessTokenResp;
|
||||
use bichon_core::users::permissions::Permission;
|
||||
use bichon_core::{token::payload::AccessTokenCreateRequest, token::AccessTokenModel};
|
||||
@@ -60,8 +61,18 @@ impl AccessTokenApi {
|
||||
if context.user.id != token.user_id {
|
||||
context.require_permission(None, Permission::TOKEN_MANAGE)?;
|
||||
}
|
||||
|
||||
Ok(AccessTokenModel::delete(&token.token)?)
|
||||
let token_name = token.name.clone();
|
||||
let token_user = token.user_id;
|
||||
AccessTokenModel::delete(&token.token)?;
|
||||
let target_username = bichon_core::users::UserModel::find(token_user)?
|
||||
.map(|u| u.username)
|
||||
.unwrap_or_else(|| format!("user-{token_user}"));
|
||||
emit(Event::AccessTokenRemoved {
|
||||
user: context.user.username.clone(),
|
||||
token_user: target_username,
|
||||
name: token_name,
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Creates a new api token.
|
||||
@@ -81,8 +92,16 @@ impl AccessTokenApi {
|
||||
if target_user_id != current_user_id {
|
||||
context.require_permission(None, Permission::USER_MANAGE)?;
|
||||
}
|
||||
|
||||
let token_name = payload.0.name.clone();
|
||||
let token_string = AccessTokenModel::create_api_token(target_user_id, payload.0)?;
|
||||
let target_username = bichon_core::users::UserModel::find(target_user_id)?
|
||||
.map(|u| u.username)
|
||||
.unwrap_or_else(|| format!("user-{target_user_id}"));
|
||||
emit(Event::AccessTokenCreated {
|
||||
user: context.user.username.clone(),
|
||||
target_user: target_username,
|
||||
name: token_name,
|
||||
});
|
||||
Ok(PlainText(token_string))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,9 +27,10 @@ use bichon_core::account::payload::{
|
||||
use bichon_core::account::state::{DownloadState, GapFillState};
|
||||
use bichon_core::account::stats::AccountStats;
|
||||
use bichon_core::account::view::AccountResp;
|
||||
use bichon_core::cache::imap::task::SYNC_TASKS;
|
||||
use bichon_core::archive::imap::task::SYNC_TASKS;
|
||||
use bichon_core::common::paginated::{paginate_vec, DataPage};
|
||||
use bichon_core::error::code::ErrorCode;
|
||||
use bichon_core::ext::event_bus::{emit, Event};
|
||||
use bichon_core::raise_error;
|
||||
use bichon_core::store::tantivy::envelope::ENVELOPE_MANAGER;
|
||||
use bichon_core::users::permissions::Permission;
|
||||
@@ -86,7 +87,15 @@ impl AccountApi {
|
||||
) -> ApiResult<()> {
|
||||
let account_id = account_id.0;
|
||||
context.require_permission(Some(account_id), Permission::ACCOUNT_MANAGE)?;
|
||||
let email = AccountModel::find(account_id)?
|
||||
.map(|a| a.email)
|
||||
.unwrap_or_else(|| format!("account-{account_id}"));
|
||||
AccountModel::delete(account_id).await?;
|
||||
emit(Event::AccountRemoved {
|
||||
removed_by: context.user.username.clone(),
|
||||
account_id,
|
||||
email,
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -100,6 +109,11 @@ impl AccountApi {
|
||||
) -> ApiResult<Json<AccountModel>> {
|
||||
context.require_permission(None, Permission::ACCOUNT_CREATE)?;
|
||||
let account = AccountModel::create_account(context.user.id, payload.0).await?;
|
||||
emit(Event::AccountCreated {
|
||||
created_by: context.user.username.clone(),
|
||||
account_id: account.id,
|
||||
email: account.email.clone(),
|
||||
});
|
||||
Ok(Json(account))
|
||||
}
|
||||
|
||||
@@ -119,7 +133,16 @@ impl AccountApi {
|
||||
) -> ApiResult<()> {
|
||||
let account_id = account_id.0;
|
||||
context.require_permission(Some(account_id), Permission::ACCOUNT_MANAGE)?;
|
||||
Ok(AccountModel::update(account_id, payload.0, true)?)
|
||||
AccountModel::update(account_id, payload.0, true)?;
|
||||
let email = AccountModel::find(account_id)?
|
||||
.map(|a| a.email)
|
||||
.unwrap_or_else(|| format!("account-{account_id}"));
|
||||
emit(Event::AccountUpdated {
|
||||
updated_by: context.user.username.clone(),
|
||||
account_id,
|
||||
email,
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// List accounts with optional pagination parameters
|
||||
@@ -255,6 +278,11 @@ impl AccountApi {
|
||||
}
|
||||
context.require_permission(Some(account_id), Permission::ACCOUNT_MANAGE)?;
|
||||
SYNC_TASKS.start_manual_task(account_id, body.run_gap_fill).await?;
|
||||
emit(Event::AccountDownloadStarted {
|
||||
user: context.user.username.clone(),
|
||||
account_id,
|
||||
run_gap_fill: body.run_gap_fill,
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -288,6 +316,10 @@ impl AccountApi {
|
||||
))?;
|
||||
}
|
||||
SYNC_TASKS.cancel_manual_task(account_id).await;
|
||||
emit(Event::AccountDownloadStopped {
|
||||
user: context.user.username.clone(),
|
||||
account_id,
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -343,8 +375,25 @@ impl AccountApi {
|
||||
req: Json<BatchAccountRoleRequest>,
|
||||
context: WrappedContext,
|
||||
) -> ApiResult<()> {
|
||||
let req = req.0;
|
||||
req.validate_existence()?;
|
||||
req.0.do_assign(&context)?;
|
||||
let role_name = bichon_core::users::role::UserRole::find(req.role_id)?
|
||||
.map(|r| r.name)
|
||||
.unwrap_or_else(|| format!("role-{}", req.role_id));
|
||||
let target_users: Vec<String> = req
|
||||
.user_ids
|
||||
.iter()
|
||||
.filter_map(|uid| UserModel::find(*uid).ok().flatten())
|
||||
.map(|u| u.username)
|
||||
.collect();
|
||||
let account_count = req.account_ids.len();
|
||||
req.do_assign(&context)?;
|
||||
emit(Event::AccountRoleAssigned {
|
||||
user: context.user.username.clone(),
|
||||
target_user: target_users.join(", "),
|
||||
account_count,
|
||||
roles: vec![role_name],
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ use crate::rest::api::ApiTags;
|
||||
use crate::rest::ApiResult;
|
||||
use bichon_core::common::paginated::DataPage;
|
||||
use bichon_core::error::code::ErrorCode;
|
||||
use bichon_core::ext::event_bus::{emit, Event};
|
||||
use bichon_core::message::attachment::AttachmentMetadata;
|
||||
use bichon_core::message::search::search_attachment_impl;
|
||||
use bichon_core::message::search::AttachmentSearchRequest;
|
||||
@@ -58,7 +59,22 @@ impl AttachmentApi {
|
||||
} else {
|
||||
Some(context.user.account_access_map.keys().cloned().collect())
|
||||
};
|
||||
Ok(Json(search_attachment_impl(authorized_ids, payload.0)?))
|
||||
let search_text = payload
|
||||
.0
|
||||
.filter()
|
||||
.text
|
||||
.clone()
|
||||
.unwrap_or_default()
|
||||
.trim()
|
||||
.to_string();
|
||||
let result = search_attachment_impl(authorized_ids, payload.0)?;
|
||||
if !search_text.is_empty() {
|
||||
emit(Event::SearchPerformed {
|
||||
query: search_text,
|
||||
user: context.user.username.clone(),
|
||||
});
|
||||
}
|
||||
Ok(Json(result))
|
||||
}
|
||||
|
||||
/// Retrieves the attachment (metadata) of a specific message.
|
||||
@@ -131,6 +147,21 @@ impl AttachmentApi {
|
||||
context.require_permission(Some(*account_id), Permission::DATA_MANAGE)?;
|
||||
}
|
||||
|
||||
let total_updates: u64 = req.updates.values().map(|ids| ids.len() as u64).sum();
|
||||
if total_updates > 0 {
|
||||
for account_id in req.updates.keys() {
|
||||
emit(Event::AttachmentTagged {
|
||||
user: context.user.username.clone(),
|
||||
account_id: *account_id,
|
||||
count: req
|
||||
.updates
|
||||
.get(account_id)
|
||||
.map(|ids| ids.len() as u64)
|
||||
.unwrap_or(0),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
ATTACHMENT_MANAGER.update_attachment_tags(req).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
//
|
||||
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
|
||||
//
|
||||
// This file is part of the Bichon Email Archiving Project
|
||||
@@ -18,44 +17,49 @@
|
||||
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use crate::common::auth::WrappedContext;
|
||||
use crate::rest::api::ApiTags;
|
||||
use crate::rest::ApiResult;
|
||||
use bichon_core::account::migration::AccountModel;
|
||||
use bichon_core::database::manager::DB_MANAGER;
|
||||
use bichon_core::database::MemDbModel;
|
||||
use bichon_core::import::{
|
||||
check_temp_disk_space, get_import_progress, process_uploaded_file, update_progress,
|
||||
BatchEmlRequest, BatchEmlResult, ImportEmls, ImportHistory, ImportProgress, ImportStatus,
|
||||
MAX_WEB_EML_BYTES,
|
||||
use bichon_core::{
|
||||
account::migration::AccountModel,
|
||||
database::{manager::DB_MANAGER, MemDbModel},
|
||||
error::code::ErrorCode,
|
||||
ext::event_bus::{emit, Event},
|
||||
import::{
|
||||
check_temp_disk_space, detect_text_file, get_import_progress,
|
||||
history::{save_import_history, MAX_HISTORY_PER_USER},
|
||||
process_uploaded_file, update_progress, BatchEmlRequest, BatchEmlResult, FileFormat,
|
||||
ImportEmls, ImportHistory, ImportProgress, ImportStatus, MAX_WEB_EML_BYTES,
|
||||
},
|
||||
raise_error,
|
||||
settings::{cli::SETTINGS, dir::DATA_DIR_MANAGER},
|
||||
users::permissions::Permission,
|
||||
};
|
||||
use bichon_core::import::history::{save_import_history, MAX_HISTORY_PER_USER};
|
||||
use bichon_core::raise_error;
|
||||
use bichon_core::error::code::ErrorCode;
|
||||
use bichon_core::settings::cli::SETTINGS;
|
||||
use bichon_core::settings::dir::DATA_DIR_MANAGER;
|
||||
use bichon_core::users::permissions::Permission;
|
||||
use bichon_core::import::detect_text_file;
|
||||
use bichon_core::import::FileFormat;
|
||||
use futures::StreamExt;
|
||||
use poem::Body;
|
||||
use poem_openapi::param::{Path, Query};
|
||||
use poem_openapi::payload::{Json, Binary};
|
||||
use poem_openapi::OpenApi;
|
||||
use poem_openapi::{
|
||||
param::{Path, Query},
|
||||
payload::{Binary, Json},
|
||||
OpenApi,
|
||||
};
|
||||
use tokio::io::AsyncWriteExt;
|
||||
|
||||
use crate::{
|
||||
common::auth::WrappedContext,
|
||||
rest::{api::ApiTags, ApiResult},
|
||||
};
|
||||
|
||||
pub struct ImportApi;
|
||||
|
||||
#[OpenApi(prefix_path = "/api/v1", tag = "ApiTags::Import")]
|
||||
impl ImportApi {
|
||||
/// Batch import one or more EML files into a specified account and mail folder.
|
||||
/// Batch import one or more EML files into a specified account and mail
|
||||
/// folder.
|
||||
///
|
||||
/// This endpoint accepts a JSON payload containing:
|
||||
/// - `account_id`: the target account to import emails into
|
||||
/// - `mail_folder`: the mailbox/folder name
|
||||
/// - `emls`: a list of base64-encoded .eml files
|
||||
///
|
||||
/// Returns a summary of the import result, including total processed, successful, and failed emails.
|
||||
/// Returns a summary of the import result, including total processed,
|
||||
/// successful, and failed emails.
|
||||
#[oai(path = "/import", method = "post", operation_id = "do_batch_import")]
|
||||
async fn do_batch_import(
|
||||
&self,
|
||||
@@ -67,6 +71,15 @@ impl ImportApi {
|
||||
let folder = payload.0.mail_folder.clone();
|
||||
context.require_permission(Some(account_id), Permission::DATA_IMPORT_BATCH)?;
|
||||
let result = ImportEmls::do_import(payload.0).await?;
|
||||
emit(Event::ImportPerformed {
|
||||
user: context.user.username.clone(),
|
||||
account_id,
|
||||
format: "eml".to_string(),
|
||||
total: result.total as u64,
|
||||
success: result.success as u64,
|
||||
duplicates: result.duplicates as u64,
|
||||
failed: result.failed as u64,
|
||||
});
|
||||
|
||||
// Save import history
|
||||
let progress = ImportProgress {
|
||||
@@ -79,7 +92,7 @@ impl ImportApi {
|
||||
),
|
||||
status: if result.failed == 0 {
|
||||
ImportStatus::Completed
|
||||
} else if result.success == 0 {
|
||||
} else if result.success == 0 && result.duplicates == 0 {
|
||||
ImportStatus::Failed
|
||||
} else {
|
||||
ImportStatus::Completed
|
||||
@@ -98,19 +111,25 @@ impl ImportApi {
|
||||
|
||||
/// Upload an EML or MBOX file for import into a NoSync account.
|
||||
///
|
||||
/// The file is sent as the raw request body. Both `account_id` and `mail_folder`
|
||||
/// must be provided as query parameters, along with the original `file_name` for
|
||||
/// extension validation.
|
||||
/// The file is sent as the raw request body. Both `account_id` and
|
||||
/// `mail_folder` must be provided as query parameters, along with the
|
||||
/// original `file_name` for extension validation.
|
||||
///
|
||||
/// Returns an `import_id` to poll for progress via `/import-progress/:import_id`.
|
||||
#[oai(path = "/upload-import", method = "post", operation_id = "upload_import")]
|
||||
/// Returns an `import_id` to poll for progress via
|
||||
/// `/import-progress/:import_id`.
|
||||
#[oai(
|
||||
path = "/upload-import",
|
||||
method = "post",
|
||||
operation_id = "upload_import"
|
||||
)]
|
||||
async fn upload_import(
|
||||
&self,
|
||||
/// Target account ID (must be NoSync type).
|
||||
account_id: Query<u64>,
|
||||
/// Target mail folder name.
|
||||
mail_folder: Query<String>,
|
||||
/// Original file name, used for extension validation (e.g. "export.eml").
|
||||
/// Original file name, used for extension validation (e.g.
|
||||
/// "export.eml").
|
||||
file_name: Query<String>,
|
||||
/// The raw file bytes (.eml or .mbox).
|
||||
data: Binary<Body>,
|
||||
@@ -188,14 +207,12 @@ impl ImportApi {
|
||||
.unwrap_or_default()
|
||||
.as_nanos()
|
||||
);
|
||||
let temp_path = DATA_DIR_MANAGER.temp_dir.join(format!("import_{}.tmp", import_id));
|
||||
let temp_path = DATA_DIR_MANAGER
|
||||
.temp_dir
|
||||
.join(format!("import_{}.tmp", import_id));
|
||||
|
||||
let (format_detected, file_len) = stream_body_to_temp(
|
||||
data.0,
|
||||
&temp_path,
|
||||
is_mbox_ext,
|
||||
is_pst_ext,
|
||||
).await?;
|
||||
let (format_detected, file_len) =
|
||||
stream_body_to_temp(data.0, &temp_path, is_mbox_ext, is_pst_ext).await?;
|
||||
|
||||
let format = format_detected.unwrap_or_else(|| {
|
||||
if is_mbox_ext {
|
||||
@@ -252,8 +269,24 @@ impl ImportApi {
|
||||
let id = import_id.clone();
|
||||
let folder_clone = folder.clone();
|
||||
let user_id = context.user.id;
|
||||
emit(Event::ImportPerformed {
|
||||
user: context.user.username.clone(),
|
||||
account_id,
|
||||
format: format_str.clone(),
|
||||
total: 0,
|
||||
success: 0,
|
||||
duplicates: 0,
|
||||
failed: 0,
|
||||
});
|
||||
tokio::task::spawn_blocking(move || {
|
||||
process_uploaded_file(&id, &temp_path, &file_name, account_id, &folder_clone, user_id);
|
||||
process_uploaded_file(
|
||||
&id,
|
||||
&temp_path,
|
||||
&file_name,
|
||||
account_id,
|
||||
&folder_clone,
|
||||
user_id,
|
||||
);
|
||||
});
|
||||
|
||||
Ok(Json(initial))
|
||||
@@ -291,7 +324,8 @@ impl ImportApi {
|
||||
Ok(Json(free))
|
||||
}
|
||||
|
||||
/// List import history for the current user (latest first, up to 5 entries).
|
||||
/// List import history for the current user (latest first, up to 5
|
||||
/// entries).
|
||||
#[oai(
|
||||
path = "/import-history",
|
||||
method = "get",
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
use crate::common::auth::WrappedContext;
|
||||
use crate::rest::api::ApiTags;
|
||||
use crate::rest::ApiResult;
|
||||
use bichon_core::ext::event_bus::{emit, Event};
|
||||
use bichon_core::mailbox::delete::delete_mailbox_impl;
|
||||
use bichon_core::mailbox::list::{get_account_mailboxes, MailboxListResponse};
|
||||
use bichon_core::users::permissions::Permission;
|
||||
@@ -80,6 +81,12 @@ impl MailBoxApi {
|
||||
let account_id = account_id.0;
|
||||
let mailbox_id = mailbox_id.0;
|
||||
context.require_permission(Some(account_id), Permission::DATA_DELETE)?;
|
||||
Ok(delete_mailbox_impl(account_id, mailbox_id).await?)
|
||||
delete_mailbox_impl(account_id, mailbox_id).await?;
|
||||
emit(Event::MailboxRemoved {
|
||||
user: context.user.username.clone(),
|
||||
account_id,
|
||||
mailbox_id,
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ use bichon_core::message::content::retrieve_nested_eml_content;
|
||||
use bichon_core::message::content::FullNestedMessageContent;
|
||||
use bichon_core::message::content::{retrieve_email_content, FullMessageContent};
|
||||
use bichon_core::message::delete::delete_messages_impl;
|
||||
use bichon_core::ext::event_bus::{emit, Event, EventPayload};
|
||||
use bichon_core::message::list::get_thread_messages;
|
||||
use bichon_core::message::search::{search_messages_impl, EmailSearchRequest};
|
||||
use bichon_core::message::tags::TagCount;
|
||||
@@ -67,7 +68,23 @@ impl MessageApi {
|
||||
for account_id in request.keys() {
|
||||
context.require_permission(Some(*account_id), Permission::DATA_DELETE)?;
|
||||
}
|
||||
Ok(delete_messages_impl(request).await?)
|
||||
// Audit: capture the subject and a content snapshot BEFORE the
|
||||
// messages are gone, so the audit trail stays self-describing.
|
||||
let user = context.user.username.clone();
|
||||
let snapshots = audit_snapshots_for_deleted(&request);
|
||||
let result = delete_messages_impl(request).await;
|
||||
for (account_id, email_id, mailbox_id, subject, snapshot) in snapshots {
|
||||
emit(Event::EmailDeleted {
|
||||
email_id,
|
||||
user: user.clone(),
|
||||
account_id,
|
||||
mailbox_id,
|
||||
subject,
|
||||
snapshot,
|
||||
});
|
||||
}
|
||||
result?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Searches messages across all mailboxes using various filter criteria.
|
||||
@@ -88,7 +105,23 @@ impl MessageApi {
|
||||
} else {
|
||||
Some(context.user.account_access_map.keys().cloned().collect())
|
||||
};
|
||||
Ok(Json(search_messages_impl(authorized_ids, payload.0)?))
|
||||
let search_text = payload
|
||||
.0
|
||||
.filter
|
||||
.text
|
||||
.clone()
|
||||
.unwrap_or_default()
|
||||
.trim()
|
||||
.to_string();
|
||||
let user = context.user.username.clone();
|
||||
let result = search_messages_impl(authorized_ids, payload.0)?;
|
||||
if !search_text.is_empty() {
|
||||
emit(Event::SearchPerformed {
|
||||
query: search_text,
|
||||
user,
|
||||
});
|
||||
}
|
||||
Ok(Json(result))
|
||||
}
|
||||
|
||||
/// Retrieves all messages belonging to a specific thread. Requires `thread_id`, `page`, and `page_size` query parameters.
|
||||
@@ -141,11 +174,22 @@ impl MessageApi {
|
||||
let account_id = account_id.0;
|
||||
let block_remote = block_remote_content.0.unwrap_or(false);
|
||||
context.require_permission(Some(account_id), Permission::DATA_READ)?;
|
||||
Ok(Json(retrieve_email_content(
|
||||
account_id,
|
||||
envelope_id.0,
|
||||
block_remote,
|
||||
)?))
|
||||
let envelope_id = envelope_id.0.trim().to_string();
|
||||
let envelope = ENVELOPE_MANAGER
|
||||
.get_envelope_by_id(account_id, &envelope_id)?
|
||||
.map(|ea| ea.envelope);
|
||||
let content = retrieve_email_content(account_id, envelope_id.clone(), block_remote)?;
|
||||
if let Some(ip) = context.ip_addr {
|
||||
emit(Event::EmailViewed {
|
||||
email_id: envelope_id.clone(),
|
||||
user: context.user.username.clone(),
|
||||
ip,
|
||||
account_id,
|
||||
mailbox_id: envelope.as_ref().map(|e| e.mailbox_id).unwrap_or(0),
|
||||
subject: envelope.as_ref().map(|e| e.subject.clone()),
|
||||
});
|
||||
}
|
||||
Ok(Json(content))
|
||||
}
|
||||
|
||||
/// Retrieves the content of an email embedded as an attachment.
|
||||
@@ -225,6 +269,17 @@ impl MessageApi {
|
||||
AccountModel::check_account_exists(account_id)?;
|
||||
context.require_permission(Some(account_id), Permission::DATA_RAW_DOWNLOAD)?;
|
||||
let envelope_id = envelope_id.0;
|
||||
let subject = ENVELOPE_MANAGER
|
||||
.get_envelope_by_id(account_id, &envelope_id)
|
||||
.ok()
|
||||
.flatten()
|
||||
.map(|ea| ea.envelope.subject.clone());
|
||||
emit(Event::EmailExported {
|
||||
email_id: envelope_id.clone(),
|
||||
user: context.user.username.clone(),
|
||||
account_id,
|
||||
subject,
|
||||
});
|
||||
let reader = get_reader(account_id, envelope_id.clone()).await?;
|
||||
let body = Body::from_async_read(reader);
|
||||
let attachment = Attachment::new(body)
|
||||
@@ -248,6 +303,19 @@ impl MessageApi {
|
||||
) -> ApiResult<()> {
|
||||
let account_id = account_id.0;
|
||||
context.require_permission(Some(account_id), Permission::DATA_EXPORT_BATCH)?;
|
||||
for eid in &payload.0.envelope_ids {
|
||||
let subject = ENVELOPE_MANAGER
|
||||
.get_envelope_by_id(account_id, eid)
|
||||
.ok()
|
||||
.flatten()
|
||||
.map(|ea| ea.envelope.subject.clone());
|
||||
emit(Event::EmailRestored {
|
||||
email_id: eid.clone(),
|
||||
user: context.user.username.clone(),
|
||||
account_id,
|
||||
subject,
|
||||
});
|
||||
}
|
||||
Ok(restore_emails(account_id, payload.0.envelope_ids).await?)
|
||||
}
|
||||
|
||||
@@ -272,7 +340,19 @@ impl MessageApi {
|
||||
AccountModel::check_account_exists(account_id)?;
|
||||
context.require_permission(Some(account_id), Permission::DATA_READ)?;
|
||||
let content_hash = content_hash.0.trim();
|
||||
let reader = retrieve_attachment_content(account_id, envelope_id, content_hash)?;
|
||||
let meta = attachment_meta_for_audit(account_id, &envelope_id, content_hash);
|
||||
let reader = retrieve_attachment_content(account_id, envelope_id.clone(), content_hash)?;
|
||||
emit(Event::AttachmentDownloaded {
|
||||
email_id: envelope_id.clone(),
|
||||
content_hash: content_hash.to_string(),
|
||||
user: context.user.username.clone(),
|
||||
account_id,
|
||||
mailbox_id: meta.mailbox_id,
|
||||
filename: meta.filename,
|
||||
size: meta.size,
|
||||
ext: meta.ext,
|
||||
parent_content_hash: meta.parent_content_hash,
|
||||
});
|
||||
let body = Body::from_async_read(reader);
|
||||
let attachment = Attachment::new(body)
|
||||
.attachment_type(AttachmentType::Attachment)
|
||||
@@ -302,7 +382,16 @@ impl MessageApi {
|
||||
AccountModel::check_account_exists(account_id)?;
|
||||
context.require_permission(Some(account_id), Permission::DATA_READ)?;
|
||||
let content_hash = content_hash.0.trim();
|
||||
let reader = retrieve_attachment_content(account_id, envelope_id, content_hash)?;
|
||||
let meta = attachment_meta_for_audit(account_id, &envelope_id, content_hash);
|
||||
let reader = retrieve_attachment_content(account_id, envelope_id.clone(), content_hash)?;
|
||||
emit(Event::AttachmentPreviewed {
|
||||
email_id: envelope_id.clone(),
|
||||
content_hash: content_hash.to_string(),
|
||||
user: context.user.username.clone(),
|
||||
account_id,
|
||||
mailbox_id: meta.mailbox_id,
|
||||
filename: meta.filename,
|
||||
});
|
||||
let body = Body::from_async_read(reader);
|
||||
Ok(Attachment::new(body).attachment_type(AttachmentType::Inline))
|
||||
}
|
||||
@@ -330,12 +419,24 @@ impl MessageApi {
|
||||
context.require_permission(Some(account_id), Permission::DATA_READ)?;
|
||||
let content_hash = content_hash.0.trim();
|
||||
let nested_content_hash = nested_content_hash.0.trim();
|
||||
let meta = attachment_meta_for_audit(account_id, &envelope_id, content_hash);
|
||||
let reader = retrieve_nested_attachment_content(
|
||||
account_id,
|
||||
envelope_id,
|
||||
envelope_id.clone(),
|
||||
content_hash,
|
||||
nested_content_hash,
|
||||
)?;
|
||||
emit(Event::AttachmentDownloaded {
|
||||
email_id: envelope_id.clone(),
|
||||
content_hash: nested_content_hash.to_string(),
|
||||
user: context.user.username.clone(),
|
||||
account_id,
|
||||
mailbox_id: meta.mailbox_id,
|
||||
filename: None,
|
||||
size: None,
|
||||
ext: None,
|
||||
parent_content_hash: meta.parent_content_hash,
|
||||
});
|
||||
let body = Body::from_async_read(reader);
|
||||
let attachment = Attachment::new(body)
|
||||
.attachment_type(AttachmentType::Attachment)
|
||||
@@ -375,6 +476,21 @@ impl MessageApi {
|
||||
context.require_permission(Some(*account_id), Permission::DATA_MANAGE)?;
|
||||
}
|
||||
|
||||
let total_updates: u64 = req.updates.values().map(|ids| ids.len() as u64).sum();
|
||||
if total_updates > 0 {
|
||||
for account_id in req.updates.keys() {
|
||||
emit(Event::EmailTagged {
|
||||
user: context.user.username.clone(),
|
||||
account_id: *account_id,
|
||||
count: req
|
||||
.updates
|
||||
.get(account_id)
|
||||
.map(|ids| ids.len() as u64)
|
||||
.unwrap_or(0),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
ENVELOPE_MANAGER.update_envelope_tags(req).await?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -395,3 +511,95 @@ impl MessageApi {
|
||||
Ok(Json(ENVELOPE_MANAGER.get_all_contacts(authorized_ids)?))
|
||||
}
|
||||
}
|
||||
|
||||
/// Attachment metadata captured for the audit trail.
|
||||
struct AttachmentAuditMeta {
|
||||
mailbox_id: u64,
|
||||
filename: Option<String>,
|
||||
size: Option<u64>,
|
||||
ext: Option<String>,
|
||||
parent_content_hash: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for AttachmentAuditMeta {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
mailbox_id: 0,
|
||||
filename: None,
|
||||
size: None,
|
||||
ext: None,
|
||||
parent_content_hash: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolves attachment display metadata (name, size, extension) and the
|
||||
/// parent envelope's mailbox/content hash, for the audit trail. Best-effort:
|
||||
/// failures degrade to defaults rather than failing the download.
|
||||
fn attachment_meta_for_audit(
|
||||
account_id: u64,
|
||||
envelope_id: &str,
|
||||
content_hash: &str,
|
||||
) -> AttachmentAuditMeta {
|
||||
let mut meta = AttachmentAuditMeta::default();
|
||||
if let Ok(Some(ea)) = ENVELOPE_MANAGER.get_envelope_by_id(account_id, envelope_id) {
|
||||
meta.mailbox_id = ea.envelope.mailbox_id;
|
||||
meta.parent_content_hash = Some(ea.envelope.content_hash);
|
||||
if let Some(atts) = ea.attachments {
|
||||
for att in atts {
|
||||
if att.content_hash == content_hash {
|
||||
meta.filename = att.filename.clone();
|
||||
meta.size = Some(att.size as u64);
|
||||
meta.ext = att
|
||||
.filename
|
||||
.as_ref()
|
||||
.and_then(|n| std::path::Path::new(n).extension())
|
||||
.and_then(|e| e.to_str())
|
||||
.map(|s| s.to_ascii_lowercase());
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
meta
|
||||
}
|
||||
|
||||
/// Collects (account_id, email_id, mailbox_id, subject, snapshot) for every
|
||||
/// message about to be deleted, so the audit trail keeps a readable record
|
||||
/// of what was removed.
|
||||
fn audit_snapshots_for_deleted(
|
||||
request: &HashMap<u64, Vec<String>>,
|
||||
) -> Vec<(u64, String, u64, Option<String>, Option<EventPayload>)> {
|
||||
let mut out = Vec::new();
|
||||
for (account_id, envelope_ids) in request {
|
||||
for eid in envelope_ids {
|
||||
let mut mailbox_id = 0u64;
|
||||
let mut subject = None;
|
||||
let mut snapshot: EventPayload = serde_json::Map::new();
|
||||
if let Ok(Some(ea)) = ENVELOPE_MANAGER.get_envelope_by_id(*account_id, eid) {
|
||||
let e = ea.envelope;
|
||||
mailbox_id = e.mailbox_id;
|
||||
subject = Some(e.subject.clone());
|
||||
snapshot.insert("from".into(), serde_json::json!(e.from));
|
||||
snapshot.insert("date".into(), serde_json::json!(e.date));
|
||||
snapshot.insert("size".into(), serde_json::json!(e.size));
|
||||
snapshot.insert(
|
||||
"attachment_count".into(),
|
||||
serde_json::json!(e.regular_attachment_count),
|
||||
);
|
||||
if let Some(atts) = ea.attachments {
|
||||
let names: Vec<String> = atts
|
||||
.iter()
|
||||
.filter_map(|a| a.filename.clone())
|
||||
.collect();
|
||||
if !names.is_empty() {
|
||||
snapshot.insert("attachment_names".into(), serde_json::json!(names));
|
||||
}
|
||||
}
|
||||
snapshot.insert("content_hash".into(), serde_json::json!(e.content_hash));
|
||||
}
|
||||
out.push((*account_id, eid.clone(), mailbox_id, subject, Some(snapshot)));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ use bichon_core::error::code::ErrorCode;
|
||||
use crate::common::auth::WrappedContext;
|
||||
use crate::rest::api::ApiTags;
|
||||
use crate::rest::ApiResult;
|
||||
use bichon_core::ext::event_bus::{emit, Event};
|
||||
use bichon_core::oauth2::entity::{OAuth2, OAuth2CreateRequest, OAuth2UpdateRequest};
|
||||
use bichon_core::oauth2::flow::{AuthorizeUrlRequest, OAuth2Flow};
|
||||
use bichon_core::oauth2::token::{ExternalOAuth2Request, OAuth2AccessToken};
|
||||
@@ -81,7 +82,17 @@ impl OAuth2Api {
|
||||
context: WrappedContext,
|
||||
) -> ApiResult<()> {
|
||||
context.require_permission(None, Permission::ROOT)?;
|
||||
Ok(OAuth2::delete(id.0)?)
|
||||
let id = id.0;
|
||||
let name = OAuth2::get(id)?
|
||||
.map(|o| o.description.unwrap_or_default())
|
||||
.unwrap_or_else(|| format!("oauth2-{id}"));
|
||||
OAuth2::delete(id)?;
|
||||
emit(Event::OAuth2ConfigRemoved {
|
||||
user: context.user.username.clone(),
|
||||
oauth2_id: id,
|
||||
name,
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Creates a new OAuth2 configuration.
|
||||
@@ -100,8 +111,16 @@ impl OAuth2Api {
|
||||
context: WrappedContext,
|
||||
) -> ApiResult<()> {
|
||||
context.require_permission(None, Permission::ROOT)?;
|
||||
let name = request.0.description.clone().unwrap_or_default();
|
||||
let entity = OAuth2::new(request.0)?;
|
||||
Ok(entity.save()?)
|
||||
let id = entity.id;
|
||||
entity.save()?;
|
||||
emit(Event::OAuth2ConfigCreated {
|
||||
user: context.user.username.clone(),
|
||||
oauth2_id: id,
|
||||
name,
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Updates an existing OAuth2 configuration.
|
||||
@@ -122,7 +141,17 @@ impl OAuth2Api {
|
||||
context: WrappedContext,
|
||||
) -> ApiResult<()> {
|
||||
context.require_permission(None, Permission::ROOT)?;
|
||||
Ok(OAuth2::update(id.0, payload.0)?)
|
||||
let id = id.0;
|
||||
let name = OAuth2::get(id)?
|
||||
.map(|o| o.description.unwrap_or_default())
|
||||
.unwrap_or_else(|| format!("oauth2-{id}"));
|
||||
OAuth2::update(id, payload.0)?;
|
||||
emit(Event::OAuth2ConfigUpdated {
|
||||
user: context.user.username.clone(),
|
||||
oauth2_id: id,
|
||||
name,
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Lists OAuth2 configurations with pagination and sorting options.
|
||||
@@ -237,6 +266,10 @@ impl OAuth2Api {
|
||||
// Check account access permissions
|
||||
context.require_permission(Some(account_id), Permission::ACCOUNT_MANAGE)?;
|
||||
OAuth2AccessToken::upsert_external_oauth_token(account_id, request.0)?;
|
||||
emit(Event::OAuth2TokenStored {
|
||||
user: context.user.username.clone(),
|
||||
account_id,
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ use crate::rest::api::ApiTags;
|
||||
use crate::rest::ApiResult;
|
||||
use bichon_core::dashboard::DashboardStats;
|
||||
use bichon_core::error::code::ErrorCode;
|
||||
use bichon_core::ext::event_bus::{emit, Event};
|
||||
use bichon_core::raise_error;
|
||||
use bichon_core::settings::cli::SETTINGS;
|
||||
use bichon_core::settings::proxy::{Proxy, ProxyTestResult};
|
||||
@@ -88,7 +89,17 @@ impl SystemApi {
|
||||
context: WrappedContext,
|
||||
) -> ApiResult<()> {
|
||||
context.require_permission(None, Permission::ROOT)?;
|
||||
Ok(Proxy::delete(id.0)?)
|
||||
let id = id.0;
|
||||
let url = Proxy::get(id)
|
||||
.ok()
|
||||
.map(|p| p.url)
|
||||
.unwrap_or_else(|| format!("proxy-{id}"));
|
||||
Proxy::delete(id)?;
|
||||
emit(Event::ProxyRemoved {
|
||||
user: context.user.username.clone(),
|
||||
url,
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Retrieve a specific proxy configuration by ID. Requires root permission.
|
||||
@@ -118,8 +129,14 @@ impl SystemApi {
|
||||
#[oai(path = "/proxy", method = "post", operation_id = "create_proxy")]
|
||||
async fn create_proxy(&self, url: PlainText<String>, context: WrappedContext) -> ApiResult<()> {
|
||||
context.require_permission(None, Permission::ROOT)?;
|
||||
let entity = Proxy::new(url.0);
|
||||
Ok(entity.save()?)
|
||||
let url = url.0;
|
||||
let entity = Proxy::new(url.clone());
|
||||
entity.save()?;
|
||||
emit(Event::ProxyCreated {
|
||||
user: context.user.username.clone(),
|
||||
url,
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Update the URL of a specific proxy by ID. Requires root permission.
|
||||
@@ -131,7 +148,14 @@ impl SystemApi {
|
||||
context: WrappedContext,
|
||||
) -> ApiResult<()> {
|
||||
context.require_permission(None, Permission::ROOT)?;
|
||||
Ok(Proxy::update(id.0, url.0)?)
|
||||
let id = id.0;
|
||||
let url = url.0;
|
||||
Proxy::update(id, url.clone())?;
|
||||
emit(Event::ProxyUpdated {
|
||||
user: context.user.username.clone(),
|
||||
url,
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get system configurations.
|
||||
|
||||
@@ -21,6 +21,7 @@ use std::collections::BTreeMap;
|
||||
use crate::common::auth::WrappedContext;
|
||||
use crate::rest::api::ApiTags;
|
||||
use crate::rest::ApiResult;
|
||||
use bichon_core::ext::event_bus::{emit, Event};
|
||||
use bichon_core::token::AccessTokenModel;
|
||||
use bichon_core::users::minimal::MinimalUser;
|
||||
use bichon_core::users::payload::{
|
||||
@@ -53,7 +54,17 @@ impl UsersApi {
|
||||
) -> ApiResult<()> {
|
||||
let id = id.0;
|
||||
context.require_permission(None, Permission::USER_MANAGE)?;
|
||||
Ok(UserRole::delete(id)?)
|
||||
let role_name = UserRole::list_all()?
|
||||
.into_iter()
|
||||
.find(|r| r.id == id)
|
||||
.map(|r| r.name)
|
||||
.unwrap_or_else(|| format!("role-{id}"));
|
||||
UserRole::delete(id)?;
|
||||
emit(Event::RoleRemoved {
|
||||
removed_by: context.user.username.clone(),
|
||||
role_name,
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Create a new account
|
||||
@@ -66,6 +77,10 @@ impl UsersApi {
|
||||
) -> ApiResult<Json<UserRole>> {
|
||||
context.require_permission(None, Permission::USER_MANAGE)?;
|
||||
let role = UserRole::create(payload.0)?;
|
||||
emit(Event::RoleCreated {
|
||||
created_by: context.user.username.clone(),
|
||||
role_name: role.name.clone(),
|
||||
});
|
||||
Ok(Json(role))
|
||||
}
|
||||
|
||||
@@ -81,7 +96,17 @@ impl UsersApi {
|
||||
) -> ApiResult<()> {
|
||||
let id = id.0;
|
||||
context.require_permission(None, Permission::USER_MANAGE)?;
|
||||
Ok(UserRole::update(id, payload.0)?)
|
||||
let role = UserRole::list_all()?
|
||||
.into_iter()
|
||||
.find(|r| r.id == id)
|
||||
.map(|r| r.name)
|
||||
.unwrap_or_else(|| format!("role-{id}"));
|
||||
UserRole::update(id, payload.0)?;
|
||||
emit(Event::RoleUpdated {
|
||||
updated_by: context.user.username.clone(),
|
||||
role_name: role,
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[oai(path = "/list-users", method = "get", operation_id = "list_users")]
|
||||
@@ -122,7 +147,15 @@ impl UsersApi {
|
||||
) -> ApiResult<()> {
|
||||
let id = id.0;
|
||||
context.require_permission(None, Permission::USER_MANAGE)?;
|
||||
Ok(UserModel::remove(id)?)
|
||||
let target_username = UserModel::find(id)?
|
||||
.map(|u| u.username)
|
||||
.unwrap_or_else(|| format!("user-{id}"));
|
||||
UserModel::remove(id)?;
|
||||
emit(Event::UserRemoved {
|
||||
removed_by: context.user.username.clone(),
|
||||
target_user: target_username,
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[oai(path = "/users", method = "post", operation_id = "create_user")]
|
||||
@@ -133,6 +166,10 @@ impl UsersApi {
|
||||
) -> ApiResult<Json<UserView>> {
|
||||
context.require_permission(None, Permission::USER_MANAGE)?;
|
||||
let user = UserModel::create(payload.0)?;
|
||||
emit(Event::UserCreated {
|
||||
created_by: context.user.username.clone(),
|
||||
new_user: user.username.clone(),
|
||||
});
|
||||
let roles = UserRole::list_all()?;
|
||||
let role_lookup: BTreeMap<u64, UserRole> = roles.into_iter().map(|r| (r.id, r)).collect();
|
||||
Ok(Json(user.to_view(&role_lookup)))
|
||||
@@ -156,7 +193,15 @@ impl UsersApi {
|
||||
update_data.account_access_map = None;
|
||||
update_data.acl = None;
|
||||
}
|
||||
Ok(UserModel::update(target_id, update_data)?)
|
||||
UserModel::update(target_id, update_data)?;
|
||||
let target_username = UserModel::find(target_id)?
|
||||
.map(|u| u.username)
|
||||
.unwrap_or_else(|| format!("user-{target_id}"));
|
||||
emit(Event::UserUpdated {
|
||||
updated_by: context.user.username.clone(),
|
||||
target_user: target_username,
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[oai(
|
||||
|
||||
@@ -16,8 +16,11 @@
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
use bichon_core::ext::event_bus::{emit, Event};
|
||||
use bichon_core::token::AccessTokenModel;
|
||||
use bichon_core::users::UserModel;
|
||||
use poem::{handler, web::Json, IntoResponse, Response};
|
||||
use poem::web::{Json, RealIp};
|
||||
use poem::{handler, FromRequest, IntoResponse, Request, Response};
|
||||
use serde::Deserialize;
|
||||
use tracing::error;
|
||||
|
||||
@@ -32,20 +35,39 @@ pub struct LoginPayload {
|
||||
/// Accepts a plain text password and returns the `root_token`
|
||||
/// on successful authentication.
|
||||
#[handler]
|
||||
pub fn login(payload: Json<LoginPayload>) -> Response {
|
||||
let payload = payload.0;
|
||||
match UserModel::authenticate_user(payload.username, payload.password) {
|
||||
Ok(result) => match serde_json::to_string(&result) {
|
||||
Ok(json_string) => Response::builder()
|
||||
.status(http::StatusCode::OK)
|
||||
.content_type("application/json")
|
||||
.body(json_string)
|
||||
.into_response(),
|
||||
Err(_) => Response::builder()
|
||||
.status(http::StatusCode::INTERNAL_SERVER_ERROR)
|
||||
.body("Internal server error during response serialization.")
|
||||
.into_response(),
|
||||
},
|
||||
pub async fn login(payload: Json<LoginPayload>, req: &Request) -> Response {
|
||||
let login_username = payload.0.username.clone();
|
||||
match UserModel::authenticate_user(payload.0.username, payload.0.password) {
|
||||
Ok(result) => {
|
||||
// Audit: record the successful login (user + client IP).
|
||||
let username = result
|
||||
.access_token
|
||||
.as_deref()
|
||||
.and_then(|t| AccessTokenModel::resolve_user_from_token(t).ok())
|
||||
.map(|u| u.username)
|
||||
.unwrap_or(login_username);
|
||||
let ip = RealIp::from_request_without_body(req)
|
||||
.await
|
||||
.ok()
|
||||
.and_then(|r| r.0);
|
||||
if let Some(ip) = ip {
|
||||
emit(Event::UserLoggedIn {
|
||||
user: username,
|
||||
ip,
|
||||
});
|
||||
}
|
||||
match serde_json::to_string(&result) {
|
||||
Ok(json_string) => Response::builder()
|
||||
.status(http::StatusCode::OK)
|
||||
.content_type("application/json")
|
||||
.body(json_string)
|
||||
.into_response(),
|
||||
Err(_) => Response::builder()
|
||||
.status(http::StatusCode::INTERNAL_SERVER_ERROR)
|
||||
.body("Internal server error during response serialization.")
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Authentication failed with system error: {:?}", e);
|
||||
Response::builder()
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
//
|
||||
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
|
||||
//
|
||||
// This file is part of the Bichon Email Archiving Project
|
||||
@@ -16,37 +15,31 @@
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
use std::io;
|
||||
use std::net::SocketAddr;
|
||||
use std::time::Duration;
|
||||
use std::{io, net::SocketAddr, time::Duration};
|
||||
|
||||
use base64::{prelude::BASE64_STANDARD, Engine as _};
|
||||
use bichon_core::account::migration::AccountType;
|
||||
use bichon_core::cache::imap::mailbox::{Attribute, AttributeEnum};
|
||||
use bichon_core::common::signal::SIGNAL_MANAGER;
|
||||
use bichon_core::envelope::extractor::extract_envelope_from_smtp;
|
||||
use bichon_core::error::BichonResult;
|
||||
use bichon_core::settings::cli::{EncryptionMode, SETTINGS};
|
||||
use bichon_core::utils::create_hash;
|
||||
use bichon_core::{
|
||||
account::migration::AccountModel,
|
||||
cache::imap::mailbox::MailBox,
|
||||
common::auth::ClientContext,
|
||||
account::migration::{AccountModel, AccountType},
|
||||
archive::imap::mailbox::{Attribute, AttributeEnum, MailBox},
|
||||
common::{auth::ClientContext, signal::SIGNAL_MANAGER},
|
||||
envelope::extractor::extract_envelope_from_smtp,
|
||||
error::BichonResult,
|
||||
settings::cli::{EncryptionMode, SETTINGS},
|
||||
token::AccessTokenModel,
|
||||
users::{permissions::Permission, UserModel},
|
||||
utils::create_hash,
|
||||
};
|
||||
use tokio::time::timeout;
|
||||
use tokio::{
|
||||
io::{AsyncBufReadExt, AsyncRead, AsyncWrite, AsyncWriteExt},
|
||||
net::{TcpListener, TcpStream},
|
||||
sync::broadcast,
|
||||
time::timeout,
|
||||
};
|
||||
use tokio_rustls::TlsAcceptor;
|
||||
|
||||
use crate::stream::BufStream;
|
||||
use crate::tls::create_acceptor;
|
||||
use crate::{stream::BufStream, tls::create_acceptor};
|
||||
|
||||
const MAX_MAIL_SIZE: usize = 50 * 1024 * 1024; //50MB
|
||||
const MAX_MAIL_SIZE: usize = 50 * 1024 * 1024; // 50MB
|
||||
const SMTP_IDLE_TIMEOUT: Duration = Duration::from_secs(60);
|
||||
const GLOBAL_SESSION_TIMEOUT: Duration = Duration::from_secs(600);
|
||||
|
||||
@@ -394,7 +387,7 @@ where
|
||||
.await?;
|
||||
} else {
|
||||
let addr = extract_address(&trimmed[8..]);
|
||||
//println!("DEBUG: SMTP RCPT TO extracted address -> '{}'", addr);
|
||||
// println!("DEBUG: SMTP RCPT TO extracted address -> '{}'", addr);
|
||||
let account_result = AccountModel::find_by_email(addr.as_str());
|
||||
|
||||
match account_result {
|
||||
@@ -666,6 +659,7 @@ async fn parse_email(data: &[u8], session: &Session) -> BichonResult<()> {
|
||||
|
||||
extract_envelope_from_smtp(data, rcpt.id, mailbox_id)
|
||||
.await
|
||||
.map(|_| ())
|
||||
.map_err(|e| {
|
||||
tracing::error!(
|
||||
"SMTP: Envelope extraction failed for {}: {:?}",
|
||||
|
||||
61
web/src/api/audit/api.ts
Normal file
61
web/src/api/audit/api.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
//
|
||||
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
|
||||
//
|
||||
// Audit log API client (Pro edition).
|
||||
//
|
||||
|
||||
import axiosInstance from '@/api/axiosInstance'
|
||||
|
||||
export interface AuditRecord {
|
||||
id: string
|
||||
seq: number
|
||||
ts_ms: number
|
||||
event_type: string
|
||||
user: string
|
||||
account_id: number | null
|
||||
mailbox_id: number | null
|
||||
email_id: string | null
|
||||
content_hash: string | null
|
||||
ip: string | null
|
||||
payload: Record<string, unknown>
|
||||
prev_hash: string | null
|
||||
}
|
||||
|
||||
export interface AuditPageResponse {
|
||||
items: AuditRecord[]
|
||||
total: number
|
||||
page: number
|
||||
page_size: number
|
||||
}
|
||||
|
||||
export interface AuditQueryParams {
|
||||
page?: number
|
||||
page_size?: number
|
||||
start_ms?: number
|
||||
end_ms?: number
|
||||
user?: string
|
||||
event_type?: string
|
||||
account_id?: number
|
||||
email_id?: string
|
||||
}
|
||||
|
||||
export async function list_audit_log(
|
||||
params: AuditQueryParams,
|
||||
): Promise<AuditPageResponse> {
|
||||
const { data } = await axiosInstance.get<AuditPageResponse>('api/v1/audit-log', {
|
||||
params,
|
||||
})
|
||||
return data
|
||||
}
|
||||
|
||||
export async function list_audit_log_by_email(
|
||||
envelopeId: string,
|
||||
page: number,
|
||||
page_size: number,
|
||||
): Promise<AuditPageResponse> {
|
||||
const { data } = await axiosInstance.get<AuditPageResponse>(
|
||||
`api/v1/audit-log/email/${envelopeId}`,
|
||||
{ params: { page, page_size } },
|
||||
)
|
||||
return data
|
||||
}
|
||||
52
web/src/api/license/api.ts
Normal file
52
web/src/api/license/api.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
//
|
||||
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
|
||||
//
|
||||
// This file is part of the Bichon Email Archiving Project
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import axiosInstance from '@/api/axiosInstance'
|
||||
|
||||
export interface LicenseStatusResponse {
|
||||
status: string
|
||||
email?: string | null
|
||||
edition?: string | null
|
||||
updates_until?: string | null
|
||||
features?: string[] | null
|
||||
days_remaining?: number | null
|
||||
build_date?: string | null
|
||||
valid_until?: string | null
|
||||
machine_id: string
|
||||
account_limit?: number | null
|
||||
accounts_used: number
|
||||
}
|
||||
|
||||
export interface UploadLicenseResponse {
|
||||
success: boolean
|
||||
email: string
|
||||
edition: string
|
||||
updates_until: string
|
||||
}
|
||||
|
||||
export async function get_license_status(): Promise<LicenseStatusResponse> {
|
||||
const { data } = await axiosInstance.get<LicenseStatusResponse>('api/v1/license/status')
|
||||
return data
|
||||
}
|
||||
|
||||
export async function upload_license(license: string): Promise<UploadLicenseResponse> {
|
||||
const { data } = await axiosInstance.post<UploadLicenseResponse>('api/v1/license/upload', {
|
||||
license,
|
||||
})
|
||||
return data
|
||||
}
|
||||
@@ -27,7 +27,7 @@ import {
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover'
|
||||
import i18n from '@/i18n'
|
||||
import { dateFnsLocaleMap } from '@/lib/utils'
|
||||
import { cn, dateFnsLocaleMap } from '@/lib/utils'
|
||||
import { enUS } from 'date-fns/locale'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
@@ -35,12 +35,14 @@ type DatePickerProps = {
|
||||
selected: Date | undefined
|
||||
onSelect: (date: Date | undefined) => void
|
||||
placeholder?: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function DatePicker({
|
||||
selected,
|
||||
onSelect,
|
||||
placeholder = 'Pick a date',
|
||||
className,
|
||||
}: DatePickerProps) {
|
||||
|
||||
const currentLang = i18n.language.toLowerCase().replace('_', '-');
|
||||
@@ -60,7 +62,11 @@ export function DatePicker({
|
||||
<Button
|
||||
variant='outline'
|
||||
data-empty={!selected}
|
||||
className='data-[empty=true]:text-muted-foreground w-[240px] justify-start text-start font-normal'
|
||||
className={cn(
|
||||
'h-9 w-[240px] justify-start text-start text-sm font-normal',
|
||||
'data-[empty=true]:text-muted-foreground',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{selected ? (
|
||||
format(selected, 'PPP', { locale: dateLocale })
|
||||
|
||||
@@ -22,15 +22,19 @@ import {
|
||||
IconLayoutDashboard,
|
||||
IconSettings
|
||||
} from '@tabler/icons-react'
|
||||
import { IdCard, Inbox, Paperclip, Search, Upload, Users2 } from 'lucide-react'
|
||||
import { BadgeCheck, IdCard, Inbox, Paperclip, Search, Upload, Users2, ScrollText } from 'lucide-react'
|
||||
import { type SidebarData } from '../types'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useCurrentUser } from '@/hooks/use-current-user'
|
||||
import { useEdition } from '@/hooks/use-edition'
|
||||
|
||||
export function useSidebarData(): SidebarData {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const { require_any_permission } = useCurrentUser()
|
||||
const { features } = useEdition()
|
||||
const auditEnabled = features.includes('audit_log')
|
||||
const licenseEnabled = features.includes('license')
|
||||
|
||||
return {
|
||||
navGroups: [
|
||||
@@ -104,6 +108,18 @@ export function useSidebarData(): SidebarData {
|
||||
url: '/api-docs',
|
||||
icon: IconHelp,
|
||||
},
|
||||
{
|
||||
title: t('navigation.license'),
|
||||
url: '/license',
|
||||
icon: BadgeCheck,
|
||||
visible: licenseEnabled && require_any_permission(['system:root', 'user:manage']),
|
||||
},
|
||||
{
|
||||
title: t('navigation.auditLog'),
|
||||
url: '/audit-log',
|
||||
icon: ScrollText,
|
||||
visible: auditEnabled && require_any_permission(['system:root', 'user:manage', 'data:read:all']),
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
@@ -45,7 +45,7 @@ interface PaginationProps {
|
||||
setPageSize: (pageSize: number) => void
|
||||
}
|
||||
|
||||
export function AttachmentListPagination({
|
||||
export function TablePagination({
|
||||
totalItems,
|
||||
pageIndex,
|
||||
pageSize,
|
||||
|
||||
@@ -80,7 +80,8 @@ const VirtualizedCommand = ({
|
||||
setFilteredOptions(
|
||||
options.filter((option) =>
|
||||
option.value.toLowerCase().includes(search.toLowerCase()) ||
|
||||
option.label.toLowerCase().includes(search.toLowerCase())
|
||||
option.label.toLowerCase().includes(search.toLowerCase()) ||
|
||||
(option.description ?? '').toLowerCase().includes(search.toLowerCase())
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
@@ -25,7 +25,7 @@ import { useToast } from '@/hooks/use-toast';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { ToastAction } from '@/components/ui/toast';
|
||||
import { AxiosError } from 'axios';
|
||||
import React from 'react';
|
||||
import React, { useCallback } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { AccountModel, create_account, update_account } from '@/api/account/api';
|
||||
@@ -89,19 +89,9 @@ export function NoSyncAccountDialog({ currentRow, open, onOpenChange }: Props) {
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: create_account,
|
||||
onSuccess: handleSuccess,
|
||||
onError: handleError,
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: (data: Record<string, any>) => update_account(currentRow?.id!, data),
|
||||
onSuccess: handleSuccess,
|
||||
onError: handleError,
|
||||
});
|
||||
|
||||
function handleSuccess() {
|
||||
const handleSuccess = useCallback(() => {
|
||||
toast({
|
||||
title: isEdit ? t('accounts.accountUpdated') : t('accounts.accountCreated'),
|
||||
description: isEdit ? t('accounts.accountUpdatedDesc') : t('accounts.accountCreatedDesc'),
|
||||
@@ -111,9 +101,9 @@ export function NoSyncAccountDialog({ currentRow, open, onOpenChange }: Props) {
|
||||
queryClient.invalidateQueries({ queryKey: ['account-list'] });
|
||||
form.reset();
|
||||
onOpenChange(false);
|
||||
}
|
||||
}, [isEdit, t, toast, queryClient, form, onOpenChange]);
|
||||
|
||||
function handleError(error: AxiosError) {
|
||||
const handleError = useCallback((error: AxiosError) => {
|
||||
const errorMessage =
|
||||
(error.response?.data as { message?: string })?.message ||
|
||||
error.message ||
|
||||
@@ -126,7 +116,19 @@ export function NoSyncAccountDialog({ currentRow, open, onOpenChange }: Props) {
|
||||
action: <ToastAction altText={t('common.tryAgain')}>{t('common.tryAgain')}</ToastAction>,
|
||||
});
|
||||
console.error(error);
|
||||
}
|
||||
}, [isEdit, t, toast]);
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: create_account,
|
||||
onSuccess: handleSuccess,
|
||||
onError: handleError,
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: (data: Record<string, any>) => update_account(currentRow?.id!, data),
|
||||
onSuccess: handleSuccess,
|
||||
onError: handleError,
|
||||
});
|
||||
|
||||
const onSubmit = React.useCallback(
|
||||
(data: NoSyncAccount) => {
|
||||
|
||||
@@ -37,17 +37,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>
|
||||
}
|
||||
const hasPermission = require_any_permission(['system:root', 'account:read_details'], row.original.id)
|
||||
|
||||
// Live sync status pill. While a download session is running, poll every 5s
|
||||
// so the list always reflects progress without needing the dialog open.
|
||||
|
||||
const { data: state } = useQuery({
|
||||
queryKey: ['running-state', row.original.id],
|
||||
queryFn: () => download_state(row.original.id),
|
||||
@@ -56,6 +48,16 @@ export function RunningStateCellAction({ row }: Props) {
|
||||
return s && s.status === DownloadStatus.Running ? 5000 : false
|
||||
},
|
||||
})
|
||||
|
||||
if (row.original.deleting) {
|
||||
return <span className="text-xs text-muted-foreground italic">Deleting...</span>
|
||||
}
|
||||
|
||||
|
||||
if (row.original.account_type === "NoSync") {
|
||||
return <span className="text-xs text-muted-foreground">n/a</span>
|
||||
}
|
||||
|
||||
const running = state?.active_session
|
||||
const isRunning = !!running && running.status === DownloadStatus.Running
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { FixedHeader } from '@/components/layout/fixed-header';
|
||||
import { Main } from '@/components/layout/main';
|
||||
import { AttachmentListPagination } from '@/components/pagination';
|
||||
import { TablePagination } from '@/components/pagination';
|
||||
import React from 'react';
|
||||
import AttachmentProvider, { AttachmentDialogType } from './context';
|
||||
import useDialogState from '@/hooks/use-dialog-state';
|
||||
@@ -119,7 +119,7 @@ export default function AttachmentSearch() {
|
||||
setSortBy={setSortBy}
|
||||
setSortOrder={setSortOrder}
|
||||
/>
|
||||
{total > 0 && <AttachmentListPagination
|
||||
{total > 0 && <TablePagination
|
||||
totalItems={total}
|
||||
hasNextPage={() => page < totalPages}
|
||||
pageIndex={page - 1}
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { Loader, Download, Trash2, MessageSquareMore, FileText, FileImage, FileVideo, FileArchive, FileSpreadsheet, FileCode, FileIcon, FileAudio, Upload, ShieldCheck } from 'lucide-react';
|
||||
|
||||
@@ -167,7 +167,11 @@ export function MailMessageView({
|
||||
setBlockRemote(true);
|
||||
}, [envelope.id]);
|
||||
|
||||
const loadedKeyRef = useRef('');
|
||||
useEffect(() => {
|
||||
const key = `${envelope.account_id}:${envelope.id}:${blockRemote}`;
|
||||
if (loadedKeyRef.current === key) return;
|
||||
loadedKeyRef.current = key;
|
||||
setLoading(true);
|
||||
loadMessageMutation.mutate();
|
||||
}, [envelope.id, blockRemote]);
|
||||
|
||||
483
web/src/features/audit-log/index.tsx
Normal file
483
web/src/features/audit-log/index.tsx
Normal file
@@ -0,0 +1,483 @@
|
||||
//
|
||||
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
|
||||
//
|
||||
// Audit log page (Pro edition) — query who did what, when.
|
||||
//
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Main } from '@/components/layout/main'
|
||||
import { FixedHeader } from '@/components/layout/fixed-header'
|
||||
import { TablePagination } from '@/components/pagination'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import { TableSkeleton } from '@/components/table-skeleton'
|
||||
import { DatePicker } from '@/components/date-picker'
|
||||
import { VirtualizedSelect } from '@/components/virtualized-select'
|
||||
import { list_audit_log, type AuditRecord } from '@/api/audit/api'
|
||||
import { list_minimal_users } from '@/api/users/api'
|
||||
import { minimal_account_list } from '@/api/account/api'
|
||||
import { useEdition } from '@/hooks/use-edition'
|
||||
import { useCurrentUser } from '@/hooks/use-current-user'
|
||||
|
||||
const PAGE_SIZE = 50
|
||||
|
||||
const EVENT_TYPES = [
|
||||
'email.viewed',
|
||||
'email.deleted',
|
||||
'email.exported',
|
||||
'email.restored',
|
||||
'email.tagged',
|
||||
'attachment.downloaded',
|
||||
'attachment.previewed',
|
||||
'attachment.tagged',
|
||||
'user.login',
|
||||
'user.created',
|
||||
'user.updated',
|
||||
'user.removed',
|
||||
'role.created',
|
||||
'role.updated',
|
||||
'role.removed',
|
||||
'account.created',
|
||||
'account.updated',
|
||||
'account.removed',
|
||||
'account.download_started',
|
||||
'account.download_stopped',
|
||||
'account.role_assigned',
|
||||
'access_token.created',
|
||||
'access_token.removed',
|
||||
'oauth2.created',
|
||||
'oauth2.updated',
|
||||
'oauth2.removed',
|
||||
'oauth2.token_stored',
|
||||
'import.performed',
|
||||
'mailbox.removed',
|
||||
'proxy.created',
|
||||
'proxy.updated',
|
||||
'proxy.removed',
|
||||
'sso.login',
|
||||
'sso.logout',
|
||||
'license.uploaded',
|
||||
'search.performed',
|
||||
'settings.changed',
|
||||
] as const
|
||||
|
||||
function eventTypeLabel(t: (key: string, defaultValue: string) => string, et: string): string {
|
||||
const labels: Record<string, string> = {
|
||||
'email.viewed': t('audit.eventTypes.emailViewed', 'Email viewed'),
|
||||
'email.deleted': t('audit.eventTypes.emailDeleted', 'Email deleted'),
|
||||
'email.exported': t('audit.eventTypes.emailExported', 'Email exported'),
|
||||
'email.restored': t('audit.eventTypes.emailRestored', 'Email restored'),
|
||||
'email.tagged': t('audit.eventTypes.emailTagged', 'Email tags changed'),
|
||||
'attachment.downloaded': t('audit.eventTypes.attachmentDownloaded', 'Attachment downloaded'),
|
||||
'attachment.previewed': t('audit.eventTypes.attachmentPreviewed', 'Attachment previewed'),
|
||||
'attachment.tagged': t('audit.eventTypes.attachmentTagged', 'Attachment tags changed'),
|
||||
'user.login': t('audit.eventTypes.userLogin', 'User login'),
|
||||
'user.created': t('audit.eventTypes.userCreated', 'User created'),
|
||||
'user.updated': t('audit.eventTypes.userUpdated', 'User updated'),
|
||||
'user.removed': t('audit.eventTypes.userRemoved', 'User removed'),
|
||||
'role.created': t('audit.eventTypes.roleCreated', 'Role created'),
|
||||
'role.updated': t('audit.eventTypes.roleUpdated', 'Role updated'),
|
||||
'role.removed': t('audit.eventTypes.roleRemoved', 'Role removed'),
|
||||
'account.created': t('audit.eventTypes.accountCreated', 'Account created'),
|
||||
'account.updated': t('audit.eventTypes.accountUpdated', 'Account updated'),
|
||||
'account.removed': t('audit.eventTypes.accountRemoved', 'Account removed'),
|
||||
'account.download_started': t(
|
||||
'audit.eventTypes.accountDownloadStarted',
|
||||
'Account sync started',
|
||||
),
|
||||
'account.download_stopped': t(
|
||||
'audit.eventTypes.accountDownloadStopped',
|
||||
'Account sync stopped',
|
||||
),
|
||||
'account.role_assigned': t('audit.eventTypes.accountRoleAssigned', 'Account access assigned'),
|
||||
'access_token.created': t('audit.eventTypes.accessTokenCreated', 'Access token created'),
|
||||
'access_token.removed': t('audit.eventTypes.accessTokenRemoved', 'Access token removed'),
|
||||
'oauth2.created': t('audit.eventTypes.oauth2Created', 'OAuth2 config created'),
|
||||
'oauth2.updated': t('audit.eventTypes.oauth2Updated', 'OAuth2 config updated'),
|
||||
'oauth2.removed': t('audit.eventTypes.oauth2Removed', 'OAuth2 config removed'),
|
||||
'oauth2.token_stored': t('audit.eventTypes.oauth2TokenStored', 'OAuth2 token stored'),
|
||||
'import.performed': t('audit.eventTypes.importPerformed', 'Import performed'),
|
||||
'mailbox.removed': t('audit.eventTypes.mailboxRemoved', 'Mailbox removed'),
|
||||
'proxy.created': t('audit.eventTypes.proxyCreated', 'Proxy created'),
|
||||
'proxy.updated': t('audit.eventTypes.proxyUpdated', 'Proxy updated'),
|
||||
'proxy.removed': t('audit.eventTypes.proxyRemoved', 'Proxy removed'),
|
||||
'sso.login': t('audit.eventTypes.ssoLogin', 'SSO login'),
|
||||
'sso.logout': t('audit.eventTypes.ssoLogout', 'SSO logout'),
|
||||
'license.uploaded': t('audit.eventTypes.licenseUploaded', 'License uploaded'),
|
||||
'search.performed': t('audit.eventTypes.searchPerformed', 'Search performed'),
|
||||
'settings.changed': t('audit.eventTypes.settingsChanged', 'Settings changed'),
|
||||
}
|
||||
return labels[et] ?? et
|
||||
}
|
||||
|
||||
function formatTime(ts: number): string {
|
||||
const d = new Date(ts)
|
||||
const pad = (n: number) => String(n).padStart(2, '0')
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
|
||||
}
|
||||
|
||||
function describeEvent(rec: AuditRecord): string {
|
||||
const p = rec.payload ?? {}
|
||||
switch (rec.event_type) {
|
||||
case 'email.viewed':
|
||||
return typeof p.subject === 'string' ? p.subject : rec.email_id ?? ''
|
||||
case 'email.deleted':
|
||||
return typeof p.subject === 'string' ? p.subject : rec.email_id ?? ''
|
||||
case 'email.exported':
|
||||
case 'email.restored':
|
||||
return typeof p.subject === 'string' ? p.subject : rec.email_id ?? ''
|
||||
case 'email.tagged':
|
||||
return typeof p.count === 'number' ? `${p.count} email(s)` : ''
|
||||
case 'attachment.downloaded':
|
||||
case 'attachment.previewed':
|
||||
return typeof p.filename === 'string' ? p.filename : rec.content_hash ?? ''
|
||||
case 'attachment.tagged':
|
||||
return typeof p.count === 'number' ? `${p.count} attachment(s)` : ''
|
||||
case 'search.performed':
|
||||
return typeof p.query === 'string' ? `"${p.query}"` : ''
|
||||
case 'user.created':
|
||||
return typeof p.new_user === 'string' ? p.new_user : ''
|
||||
case 'user.updated':
|
||||
case 'user.removed':
|
||||
return typeof p.target_user === 'string' ? p.target_user : ''
|
||||
case 'role.created':
|
||||
case 'role.updated':
|
||||
case 'role.removed':
|
||||
return typeof p.role === 'string' ? p.role : ''
|
||||
case 'account.created':
|
||||
case 'account.updated':
|
||||
case 'account.removed':
|
||||
return typeof p.email === 'string' ? p.email : ''
|
||||
case 'account.download_started':
|
||||
return typeof p.run_gap_fill === 'boolean'
|
||||
? `gap_fill=${p.run_gap_fill}`
|
||||
: ''
|
||||
case 'account.role_assigned':
|
||||
return typeof p.target_user === 'string' ? p.target_user : ''
|
||||
case 'access_token.created':
|
||||
case 'access_token.removed':
|
||||
return typeof p.name === 'string' && p.name
|
||||
? p.name
|
||||
: typeof p.target_user === 'string'
|
||||
? p.target_user
|
||||
: ''
|
||||
case 'oauth2.created':
|
||||
case 'oauth2.updated':
|
||||
case 'oauth2.removed':
|
||||
return typeof p.name === 'string' && p.name ? p.name : ''
|
||||
case 'import.performed':
|
||||
return typeof p.total === 'number'
|
||||
? `total=${p.total} success=${p.success} failed=${p.failed}`
|
||||
: ''
|
||||
case 'mailbox.removed':
|
||||
return rec.mailbox_id !== null && rec.mailbox_id !== undefined
|
||||
? `mailbox ${rec.mailbox_id}`
|
||||
: ''
|
||||
case 'proxy.created':
|
||||
case 'proxy.updated':
|
||||
case 'proxy.removed':
|
||||
return typeof p.url === 'string' ? p.url : ''
|
||||
case 'license.uploaded':
|
||||
return typeof p.email === 'string' ? p.email : ''
|
||||
default:
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
function PayloadView({ record }: { record: AuditRecord }) {
|
||||
const { t } = useTranslation()
|
||||
const [open, setOpen] = useState(false)
|
||||
if (!record.payload || Object.keys(record.payload).length === 0) return null
|
||||
return (
|
||||
<div className='mt-2'>
|
||||
<Button variant='ghost' size='sm' onClick={() => setOpen((v) => !v)}>
|
||||
{open ? t('audit.hideDetails', 'Hide details') : t('audit.showDetails', 'Show details')}
|
||||
</Button>
|
||||
{open && (
|
||||
<pre className='mt-2 max-h-64 overflow-auto rounded border p-2 text-xs'>
|
||||
{JSON.stringify(record.payload, null, 2)}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function AuditLog() {
|
||||
const { t } = useTranslation()
|
||||
const { isPro } = useEdition()
|
||||
const { require_any_permission } = useCurrentUser()
|
||||
const [page, setPage] = useState(1)
|
||||
const [userFilter, setUserFilter] = useState('all')
|
||||
const [eventType, setEventType] = useState('all')
|
||||
const [accountFilter, setAccountFilter] = useState('all')
|
||||
const [startDate, setStartDate] = useState<Date | undefined>()
|
||||
const [endDate, setEndDate] = useState<Date | undefined>()
|
||||
const [applied, setApplied] = useState({
|
||||
user: 'all',
|
||||
type: 'all',
|
||||
account: 'all',
|
||||
start: undefined as Date | undefined,
|
||||
end: undefined as Date | undefined,
|
||||
})
|
||||
|
||||
const { data: users, isLoading: isUsersLoading } = useQuery({
|
||||
queryKey: ['audit-log-users'],
|
||||
queryFn: () => list_minimal_users(),
|
||||
staleTime: 60_000,
|
||||
})
|
||||
|
||||
const { data: accounts, isLoading: isAccountsLoading } = useQuery({
|
||||
queryKey: ['audit-log-accounts'],
|
||||
queryFn: () => minimal_account_list(),
|
||||
staleTime: 60_000,
|
||||
})
|
||||
|
||||
const userOptions = [
|
||||
{ value: 'all', label: t('audit.allUsers', 'All') },
|
||||
...(users ?? []).map((u) => ({
|
||||
value: u.username,
|
||||
label: `${u.username}${u.email ? ` · ${u.email}` : ''}`,
|
||||
})),
|
||||
]
|
||||
|
||||
const accountOptions = [
|
||||
{ value: 'all', label: t('audit.allAccounts', 'All') },
|
||||
...(accounts ?? []).map((a) => ({
|
||||
value: String(a.id),
|
||||
label: a.email,
|
||||
})),
|
||||
]
|
||||
|
||||
const { data, isLoading, isFetching } = useQuery({
|
||||
queryKey: ['audit-log', page, applied],
|
||||
queryFn: () =>
|
||||
list_audit_log({
|
||||
page,
|
||||
page_size: PAGE_SIZE,
|
||||
user: applied.user === 'all' || !applied.user ? undefined : applied.user,
|
||||
event_type: applied.type === 'all' || !applied.type ? undefined : applied.type,
|
||||
account_id: applied.account === 'all' || !applied.account ? undefined : Number(applied.account),
|
||||
start_ms: applied.start ? applied.start.getTime() : undefined,
|
||||
end_ms: applied.end
|
||||
? new Date(
|
||||
applied.end.getFullYear(),
|
||||
applied.end.getMonth(),
|
||||
applied.end.getDate() + 1,
|
||||
).getTime()
|
||||
: undefined,
|
||||
}),
|
||||
placeholderData: (prev) => prev,
|
||||
})
|
||||
|
||||
const applyFilters = () => {
|
||||
setPage(1)
|
||||
setApplied({
|
||||
user: userFilter,
|
||||
type: eventType,
|
||||
account: accountFilter,
|
||||
start: startDate,
|
||||
end: endDate,
|
||||
})
|
||||
}
|
||||
|
||||
const resetFilters = () => {
|
||||
setUserFilter('all')
|
||||
setEventType('all')
|
||||
setAccountFilter('all')
|
||||
setStartDate(undefined)
|
||||
setEndDate(undefined)
|
||||
setPage(1)
|
||||
setApplied({ user: 'all', type: 'all', account: 'all', start: undefined, end: undefined })
|
||||
}
|
||||
|
||||
const canView = isPro && require_any_permission(['system:root', 'user:manage', 'data:read:all'])
|
||||
|
||||
if (!canView) {
|
||||
return (
|
||||
<>
|
||||
<FixedHeader />
|
||||
<Main>
|
||||
<div className='mx-auto w-full max-w-7xl px-4 py-16 text-center text-muted-foreground'>
|
||||
{t('audit.forbidden', 'Audit log is available in the Pro edition only.')}
|
||||
</div>
|
||||
</Main>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<FixedHeader />
|
||||
<Main>
|
||||
<div className='mx-auto w-full max-w-7xl px-4'>
|
||||
<h1 className='mb-4 text-xl font-semibold'>
|
||||
{t('audit.title', 'Audit Log')}
|
||||
</h1>
|
||||
|
||||
{/* Filters */}
|
||||
<div className='mb-4 flex flex-wrap items-end gap-2'>
|
||||
<div className='flex flex-col gap-1'>
|
||||
<label className='text-xs text-muted-foreground'>
|
||||
{t('audit.user', 'User')}
|
||||
</label>
|
||||
<VirtualizedSelect
|
||||
options={userOptions}
|
||||
value={userFilter}
|
||||
onSelectOption={(values) => setUserFilter(values[0])}
|
||||
placeholder={t('audit.userPlaceholder', 'username')}
|
||||
isLoading={isUsersLoading}
|
||||
className='h-9 w-52 justify-start text-sm font-normal'
|
||||
noItemsComponent={
|
||||
<span className='text-xs'>{t('audit.noUsers', 'No users found')}</span>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className='flex flex-col gap-1'>
|
||||
<label className='text-xs text-muted-foreground'>
|
||||
{t('audit.eventType', 'Event type')}
|
||||
</label>
|
||||
<Select value={eventType} onValueChange={setEventType}>
|
||||
<SelectTrigger className='h-9 w-52 text-sm'>
|
||||
<SelectValue placeholder={t('audit.allTypes', 'All')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value='all'>{t('audit.allTypes', 'All')}</SelectItem>
|
||||
{EVENT_TYPES.map((et) => (
|
||||
<SelectItem key={et} value={et}>
|
||||
{eventTypeLabel(t, et)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className='flex flex-col gap-1'>
|
||||
<label className='text-xs text-muted-foreground'>
|
||||
{t('audit.account', 'Account')}
|
||||
</label>
|
||||
<VirtualizedSelect
|
||||
options={accountOptions}
|
||||
value={accountFilter}
|
||||
onSelectOption={(values) => setAccountFilter(values[0])}
|
||||
placeholder={t('audit.accountPlaceholder', 'Select account')}
|
||||
isLoading={isAccountsLoading}
|
||||
height='240px'
|
||||
className='h-9 w-52 justify-start text-sm font-normal'
|
||||
noItemsComponent={
|
||||
<span className='text-xs'>{t('audit.noAccounts', 'No accounts found')}</span>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className='flex flex-col gap-1'>
|
||||
<label className='text-xs text-muted-foreground'>
|
||||
{t('audit.startDate', 'Start date')}
|
||||
</label>
|
||||
<DatePicker
|
||||
placeholder={t('audit.startDate', 'Start date')}
|
||||
selected={startDate}
|
||||
onSelect={setStartDate}
|
||||
className='w-44'
|
||||
/>
|
||||
</div>
|
||||
<div className='flex flex-col gap-1'>
|
||||
<label className='text-xs text-muted-foreground'>
|
||||
{t('audit.endDate', 'End date')}
|
||||
</label>
|
||||
<DatePicker
|
||||
placeholder={t('audit.endDate', 'End date')}
|
||||
selected={endDate}
|
||||
onSelect={setEndDate}
|
||||
className='w-44'
|
||||
/>
|
||||
</div>
|
||||
<div className='ms-auto flex items-end gap-2'>
|
||||
<Button onClick={applyFilters}>{t('audit.apply', 'Apply')}</Button>
|
||||
<Button variant='outline' onClick={resetFilters}>
|
||||
{t('audit.reset', 'Reset')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<TableSkeleton columns={6} rows={10} />
|
||||
) : (
|
||||
<>
|
||||
<div className='overflow-x-auto rounded-md border'>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className='text-xs'>{t('audit.time', 'Time')}</TableHead>
|
||||
<TableHead className='text-xs'>{t('audit.user', 'User')}</TableHead>
|
||||
<TableHead className='text-xs'>{t('audit.eventType', 'Event type')}</TableHead>
|
||||
<TableHead className='text-xs'>{t('audit.detail', 'Detail')}</TableHead>
|
||||
<TableHead className='text-xs'>
|
||||
{t('audit.ip', 'IP')}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{data?.items?.map((rec) => (
|
||||
<TableRow key={rec.id}>
|
||||
<TableCell className='whitespace-nowrap text-sm'>
|
||||
{formatTime(rec.ts_ms)}
|
||||
</TableCell>
|
||||
<TableCell className='text-sm'>{rec.user}</TableCell>
|
||||
<TableCell>
|
||||
<span className='rounded bg-muted px-1.5 py-0.5 text-sm'>
|
||||
{eventTypeLabel(t, rec.event_type)}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className='max-w-md'>
|
||||
<div className='truncate text-sm'>{describeEvent(rec) || '—'}</div>
|
||||
<PayloadView record={rec} />
|
||||
</TableCell>
|
||||
<TableCell className='text-sm'>{rec.ip ?? '—'}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{data?.items?.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} className='py-8 text-center text-muted-foreground'>
|
||||
{t('audit.empty', 'No audit events found')}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
{data && data.total > 0 && <div className='mt-2'>
|
||||
<TablePagination
|
||||
totalItems={data?.total ?? 0}
|
||||
pageIndex={page - 1}
|
||||
pageSize={PAGE_SIZE}
|
||||
hasNextPage={() => (data?.total ?? 0) > page * PAGE_SIZE}
|
||||
setPageIndex={(i) => setPage(i + 1)}
|
||||
setPageSize={() => { }}
|
||||
/>
|
||||
</div>}
|
||||
{isFetching && (
|
||||
<div className='mt-2 text-xs text-muted-foreground'>
|
||||
{t('audit.loading', 'Loading…')}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Main>
|
||||
</>
|
||||
)
|
||||
}
|
||||
54
web/src/features/import/__tests__/folder-hint.test.ts
Normal file
54
web/src/features/import/__tests__/folder-hint.test.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { extractFolderHint } from '../folder-hint'
|
||||
|
||||
const emlWithLabels = [
|
||||
'From: a@example.com',
|
||||
'To: b@example.com',
|
||||
'Subject: hello',
|
||||
'X-Gmail-Labels: TestBatch',
|
||||
'',
|
||||
'body',
|
||||
].join('\n')
|
||||
|
||||
const emlPlain = ['From: a@example.com', 'Subject: hello', '', 'body'].join(
|
||||
'\n'
|
||||
)
|
||||
|
||||
describe('extractFolderHint', () => {
|
||||
it('extracts the folder from X-Gmail-Labels and records the source file', async () => {
|
||||
const hint = await extractFolderHint(
|
||||
new File([emlWithLabels], 'sample-01.eml')
|
||||
)
|
||||
|
||||
expect(hint).toEqual({
|
||||
name: 'TestBatch',
|
||||
source: 'gmail-labels',
|
||||
fileName: 'sample-01.eml',
|
||||
})
|
||||
})
|
||||
|
||||
it('unfolds folded header lines without swallowing the body', async () => {
|
||||
const folded = [
|
||||
'From: a@example.com',
|
||||
'X-Gmail-Labels: Inbox,',
|
||||
' Receipts',
|
||||
'Subject: hello',
|
||||
'',
|
||||
'body line',
|
||||
].join('\n')
|
||||
|
||||
const hint = await extractFolderHint(new File([folded], 'x.eml'))
|
||||
|
||||
expect(hint?.name).toBe('Receipts')
|
||||
})
|
||||
|
||||
it('falls back to the file name and records it as the source file', async () => {
|
||||
const hint = await extractFolderHint(new File([emlPlain], 'Receipts.eml'))
|
||||
|
||||
expect(hint).toEqual({
|
||||
name: 'Receipts',
|
||||
source: 'filename',
|
||||
fileName: 'Receipts.eml',
|
||||
})
|
||||
})
|
||||
})
|
||||
297
web/src/features/import/__tests__/import-files.test.ts
Normal file
297
web/src/features/import/__tests__/import-files.test.ts
Normal file
@@ -0,0 +1,297 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import type { ImportProgress } from '@/api/import/api'
|
||||
import { importFiles, type ImportFilesDeps } from '../import-files'
|
||||
|
||||
const makeFile = (name: string, size: number) =>
|
||||
new File([new Uint8Array(size)], name)
|
||||
|
||||
const prog = (over: Partial<ImportProgress> = {}): ImportProgress => ({
|
||||
import_id: 'imp_1',
|
||||
status: 'Completed',
|
||||
format: 'eml',
|
||||
total: 1,
|
||||
success: 1,
|
||||
duplicates: 0,
|
||||
failed: 0,
|
||||
failed_details: [],
|
||||
...over,
|
||||
})
|
||||
|
||||
const makeDeps = (over: Partial<ImportFilesDeps> = {}): ImportFilesDeps => ({
|
||||
upload: vi.fn(async () => prog()),
|
||||
getProgress: vi.fn(async () => prog()),
|
||||
onUploadPct: vi.fn(),
|
||||
onProgress: vi.fn(),
|
||||
onPhase: vi.fn(),
|
||||
pollIntervalMs: 0,
|
||||
...over,
|
||||
})
|
||||
|
||||
describe('importFiles', () => {
|
||||
it('uploads every selected file, in order', async () => {
|
||||
const files = [
|
||||
makeFile('a.eml', 10),
|
||||
makeFile('b.eml', 10),
|
||||
makeFile('c.eml', 10),
|
||||
]
|
||||
const upload = vi.fn(async (file: File) => prog({ import_id: file.name }))
|
||||
const deps = makeDeps({ upload })
|
||||
|
||||
await importFiles(files, deps)
|
||||
|
||||
expect(upload).toHaveBeenCalledTimes(3)
|
||||
expect(upload.mock.calls.map(([f]) => f.name)).toEqual([
|
||||
'a.eml',
|
||||
'b.eml',
|
||||
'c.eml',
|
||||
])
|
||||
})
|
||||
|
||||
it('reports uploading then processing for each file', async () => {
|
||||
const files = [makeFile('a.eml', 10), makeFile('b.eml', 10)]
|
||||
const deps = makeDeps({
|
||||
upload: vi.fn(async () =>
|
||||
prog({ status: 'Pending', total: 0, success: 0 })
|
||||
),
|
||||
})
|
||||
|
||||
await importFiles(files, deps)
|
||||
|
||||
expect(vi.mocked(deps.onPhase).mock.calls.map(([p]) => p)).toEqual([
|
||||
'uploading',
|
||||
'processing',
|
||||
'uploading',
|
||||
'processing',
|
||||
])
|
||||
})
|
||||
|
||||
it('aggregates counts across files', async () => {
|
||||
const files = [makeFile('a.mbox', 10), makeFile('b.mbox', 10)]
|
||||
const results: Record<string, ImportProgress> = {
|
||||
'a.mbox': prog({
|
||||
import_id: 'a',
|
||||
total: 3,
|
||||
success: 2,
|
||||
failed: 1,
|
||||
duplicates: 1,
|
||||
}),
|
||||
'b.mbox': prog({ import_id: 'b', total: 2, success: 2 }),
|
||||
}
|
||||
const deps = makeDeps({
|
||||
upload: vi.fn(async (file: File) => results[file.name]),
|
||||
})
|
||||
|
||||
const result = await importFiles(files, deps)
|
||||
|
||||
expect(result.total).toBe(5)
|
||||
expect(result.success).toBe(4)
|
||||
expect(result.failed).toBe(1)
|
||||
expect(result.duplicates).toBe(1)
|
||||
expect(result.status).toBe('Completed')
|
||||
})
|
||||
|
||||
it('polls until the import reaches a terminal status', async () => {
|
||||
const files = [makeFile('a.mbox', 10)]
|
||||
const polls = [
|
||||
prog({ status: 'Processing', total: 5, success: 2 }),
|
||||
prog({ status: 'Completed', total: 5, success: 5 }),
|
||||
]
|
||||
const getProgress = vi.fn(async () => polls.shift()!)
|
||||
const deps = makeDeps({
|
||||
upload: vi.fn(async () =>
|
||||
prog({ status: 'Pending', total: 0, success: 0 })
|
||||
),
|
||||
getProgress,
|
||||
})
|
||||
|
||||
const result = await importFiles(files, deps)
|
||||
|
||||
expect(getProgress).toHaveBeenCalledTimes(2)
|
||||
expect(result.success).toBe(5)
|
||||
const seen = vi.mocked(deps.onProgress).mock.calls.map(([p]) => p.success)
|
||||
expect(seen).toContain(2)
|
||||
})
|
||||
|
||||
it('continues past a failed upload and surfaces its error', async () => {
|
||||
const files = [makeFile('bad.eml', 10), makeFile('good.eml', 10)]
|
||||
const upload = vi.fn(async (file: File) => {
|
||||
if (file.name === 'bad.eml') {
|
||||
throw { response: { data: { message: 'not a valid email file' } } }
|
||||
}
|
||||
return prog({ import_id: 'good' })
|
||||
})
|
||||
const deps = makeDeps({ upload })
|
||||
|
||||
const result = await importFiles(files, deps)
|
||||
|
||||
expect(upload).toHaveBeenCalledTimes(2)
|
||||
expect(result.total).toBe(2)
|
||||
expect(result.success).toBe(1)
|
||||
expect(result.failed).toBe(1)
|
||||
expect(result.failed_details).toHaveLength(1)
|
||||
expect(result.failed_details[0].error_message).toContain('bad.eml')
|
||||
expect(result.failed_details[0].error_message).toContain(
|
||||
'not a valid email file'
|
||||
)
|
||||
expect(result.status).toBe('Completed')
|
||||
})
|
||||
|
||||
it('throws when every upload fails, so the caller can toast and reset', async () => {
|
||||
const files = [makeFile('a.eml', 10), makeFile('b.eml', 10)]
|
||||
const deps = makeDeps({
|
||||
upload: vi.fn(async () => {
|
||||
throw { response: { data: { message: 'server unreachable' } } }
|
||||
}),
|
||||
})
|
||||
|
||||
await expect(importFiles(files, deps)).rejects.toThrow('server unreachable')
|
||||
})
|
||||
|
||||
it('reports cumulative upload progress that never resets across files', async () => {
|
||||
const files = [makeFile('a.eml', 100), makeFile('b.eml', 300)]
|
||||
const deps = makeDeps({
|
||||
upload: vi.fn(async (_file: File, onPct: (pct: number) => void) => {
|
||||
onPct(50)
|
||||
onPct(100)
|
||||
return prog()
|
||||
}),
|
||||
})
|
||||
|
||||
await importFiles(files, deps)
|
||||
|
||||
const seen = vi.mocked(deps.onUploadPct).mock.calls.map(([pct]) => pct)
|
||||
expect(seen.length).toBeGreaterThan(0)
|
||||
for (let i = 1; i < seen.length; i++) {
|
||||
expect(seen[i]).toBeGreaterThanOrEqual(seen[i - 1])
|
||||
}
|
||||
expect(seen[seen.length - 1]).toBe(100)
|
||||
})
|
||||
|
||||
it('labels failed details with the file name only for multi-file selections', async () => {
|
||||
const failing = (id: string) =>
|
||||
prog({
|
||||
import_id: id,
|
||||
total: 2,
|
||||
success: 1,
|
||||
failed: 1,
|
||||
failed_details: [{ index: 0, error_message: 'bad message' }],
|
||||
})
|
||||
|
||||
const multi = await importFiles(
|
||||
[makeFile('a.mbox', 10), makeFile('b.mbox', 10)],
|
||||
makeDeps({ upload: vi.fn(async (file: File) => failing(file.name)) })
|
||||
)
|
||||
expect(multi.failed_details.map((d) => d.error_message)).toEqual([
|
||||
'a.mbox: bad message',
|
||||
'b.mbox: bad message',
|
||||
])
|
||||
|
||||
const single = await importFiles(
|
||||
[makeFile('a.mbox', 10)],
|
||||
makeDeps({ upload: vi.fn(async () => failing('a')) })
|
||||
)
|
||||
expect(single.failed_details.map((d) => d.error_message)).toEqual([
|
||||
'bad message',
|
||||
])
|
||||
})
|
||||
|
||||
it('gives up on a file after repeated poll errors and continues', async () => {
|
||||
const files = [makeFile('a.mbox', 10), makeFile('b.eml', 10)]
|
||||
const upload = vi.fn(async (file: File) =>
|
||||
file.name === 'a.mbox'
|
||||
? prog({ import_id: 'a', status: 'Pending', total: 0, success: 0 })
|
||||
: prog({ import_id: 'b' })
|
||||
)
|
||||
const getProgress = vi.fn(async () => {
|
||||
throw new Error('network down')
|
||||
})
|
||||
const deps = makeDeps({ upload, getProgress })
|
||||
|
||||
const result = await importFiles(files, deps)
|
||||
|
||||
expect(upload).toHaveBeenCalledTimes(2)
|
||||
expect(result.success).toBe(1)
|
||||
expect(result.failed).toBe(1)
|
||||
expect(result.failed_details[0].error_message).toContain('a.mbox')
|
||||
expect(result.status).toBe('Completed')
|
||||
})
|
||||
|
||||
it('records the file position as the index of a synthetic failure', async () => {
|
||||
const files = [
|
||||
makeFile('a.eml', 10),
|
||||
makeFile('bad.eml', 10),
|
||||
makeFile('c.eml', 10),
|
||||
]
|
||||
const deps = makeDeps({
|
||||
upload: vi.fn(async (file: File) => {
|
||||
if (file.name === 'bad.eml') throw new Error('boom')
|
||||
return prog()
|
||||
}),
|
||||
})
|
||||
|
||||
const result = await importFiles(files, deps)
|
||||
|
||||
expect(result.failed_details).toHaveLength(1)
|
||||
expect(result.failed_details[0].index).toBe(1)
|
||||
})
|
||||
|
||||
it('is Failed when the server reports a fatal failure with zero counts', async () => {
|
||||
const files = [makeFile('a.mbox', 10)]
|
||||
const deps = makeDeps({
|
||||
upload: vi.fn(async () =>
|
||||
prog({
|
||||
status: 'Failed',
|
||||
total: 0,
|
||||
success: 0,
|
||||
failed: 0,
|
||||
failed_details: [{ index: 0, error_message: 'mailbox not found' }],
|
||||
})
|
||||
),
|
||||
})
|
||||
|
||||
const result = await importFiles(files, deps)
|
||||
|
||||
expect(result.status).toBe('Failed')
|
||||
})
|
||||
|
||||
it('stops uploading and polling once aborted', async () => {
|
||||
const controller = new AbortController()
|
||||
const files = [makeFile('a.mbox', 10), makeFile('b.mbox', 10)]
|
||||
const upload = vi.fn(async () =>
|
||||
prog({ status: 'Pending', total: 0, success: 0 })
|
||||
)
|
||||
const getProgress = vi.fn(async () => {
|
||||
controller.abort()
|
||||
return prog({ status: 'Processing', total: 5, success: 1 })
|
||||
})
|
||||
const deps = makeDeps({ upload, getProgress, signal: controller.signal })
|
||||
|
||||
await importFiles(files, deps)
|
||||
|
||||
expect(upload).toHaveBeenCalledTimes(1)
|
||||
expect(getProgress).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('is Failed when nothing succeeded', async () => {
|
||||
const files = [makeFile('a.mbox', 10)]
|
||||
const deps = makeDeps({
|
||||
upload: vi.fn(async () =>
|
||||
prog({
|
||||
status: 'Failed',
|
||||
total: 2,
|
||||
success: 0,
|
||||
failed: 2,
|
||||
failed_details: [
|
||||
{ index: 0, error_message: 'parse error' },
|
||||
{ index: 1, error_message: 'parse error' },
|
||||
],
|
||||
})
|
||||
),
|
||||
})
|
||||
|
||||
const result = await importFiles(files, deps)
|
||||
|
||||
expect(result.status).toBe('Failed')
|
||||
expect(result.failed).toBe(2)
|
||||
})
|
||||
})
|
||||
@@ -44,14 +44,14 @@ function getHeader(raw: string, name: string): string | null {
|
||||
const re = new RegExp(`^${name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*:\\s*(.+)$`, 'im');
|
||||
const m = raw.match(re);
|
||||
if (!m) return null;
|
||||
// Unfold continuation lines (leading whitespace)
|
||||
// Unfold continuation lines: only contiguous lines starting with
|
||||
// horizontal whitespace belong to this header (RFC 5322 folding).
|
||||
let val = m[1].trim();
|
||||
const startIdx = m.index! + m[0].length;
|
||||
const rest = raw.slice(startIdx);
|
||||
const contRe = /^\s+(.+)$/gm;
|
||||
let cm: RegExpExecArray | null;
|
||||
while ((cm = contRe.exec(rest)) !== null) {
|
||||
val += ' ' + cm[1].trim();
|
||||
for (const line of rest.split(/\r?\n/).slice(1)) {
|
||||
if (!/^[ \t]+\S/.test(line)) break;
|
||||
val += ' ' + line.trim();
|
||||
}
|
||||
return decodeRfc2047(val);
|
||||
}
|
||||
@@ -105,6 +105,8 @@ export interface FolderHint {
|
||||
name: string;
|
||||
/** Where the hint came from. */
|
||||
source: 'gmail-labels' | 'bichon-metadata' | 'filename' | 'mbox-filename' | 'pst-filename';
|
||||
/** Name of the file the hint was extracted from. */
|
||||
fileName: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -127,26 +129,26 @@ export async function extractFolderHint(file: File): Promise<FolderHint | null>
|
||||
|
||||
// 1. X-Bichon-Metadata (highest priority, explicit)
|
||||
const bichonFolder = folderFromBichonMetadata(headers);
|
||||
if (bichonFolder) return { name: bichonFolder, source: 'bichon-metadata' };
|
||||
if (bichonFolder) return { name: bichonFolder, source: 'bichon-metadata', fileName: file.name };
|
||||
|
||||
// 2. X-Gmail-Labels
|
||||
const gmailFolder = folderFromGmailLabels(headers);
|
||||
if (gmailFolder) return { name: gmailFolder, source: 'gmail-labels' };
|
||||
if (gmailFolder) return { name: gmailFolder, source: 'gmail-labels', fileName: file.name };
|
||||
|
||||
// 3. For MBOX files, use the filename
|
||||
if (isMbox) {
|
||||
const fnFolder = folderFromFileName(file.name);
|
||||
if (fnFolder) return { name: fnFolder, source: 'mbox-filename' };
|
||||
if (fnFolder) return { name: fnFolder, source: 'mbox-filename', fileName: file.name };
|
||||
}
|
||||
|
||||
// 4. For EML files, try the filename
|
||||
const fnFolder = folderFromFileName(file.name);
|
||||
if (fnFolder) return { name: fnFolder, source: 'filename' };
|
||||
if (fnFolder) return { name: fnFolder, source: 'filename', fileName: file.name };
|
||||
|
||||
// 5. For PST files, try the filename
|
||||
if (isPst) {
|
||||
const fnFolder = folderFromFileName(file.name);
|
||||
if (fnFolder) return { name: fnFolder, source: 'pst-filename' };
|
||||
if (fnFolder) return { name: fnFolder, source: 'pst-filename', fileName: file.name };
|
||||
}
|
||||
|
||||
return null;
|
||||
|
||||
192
web/src/features/import/import-files.ts
Normal file
192
web/src/features/import/import-files.ts
Normal file
@@ -0,0 +1,192 @@
|
||||
//
|
||||
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
|
||||
//
|
||||
// This file is part of the Bichon Email Archiving Project
|
||||
import type { ImportProgress } from '@/api/import/api'
|
||||
|
||||
export interface ImportFilesDeps {
|
||||
upload: (file: File, onPct: (pct: number) => void) => Promise<ImportProgress>
|
||||
getProgress: (importId: string) => Promise<ImportProgress>
|
||||
onUploadPct: (pct: number) => void
|
||||
onProgress: (progress: ImportProgress) => void
|
||||
onPhase: (phase: 'uploading' | 'processing') => void
|
||||
signal?: AbortSignal
|
||||
pollIntervalMs?: number
|
||||
}
|
||||
|
||||
const DEFAULT_POLL_INTERVAL_MS = 1000
|
||||
const MAX_POLL_ERRORS = 5
|
||||
|
||||
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
|
||||
|
||||
const isTerminal = (status: ImportProgress['status']) =>
|
||||
status === 'Completed' || status === 'Failed'
|
||||
|
||||
function emptyProgress(): ImportProgress {
|
||||
return {
|
||||
import_id: '',
|
||||
status: 'Completed',
|
||||
format: '',
|
||||
total: 0,
|
||||
success: 0,
|
||||
duplicates: 0,
|
||||
failed: 0,
|
||||
failed_details: [],
|
||||
}
|
||||
}
|
||||
|
||||
export function errorMessage(err: unknown): string {
|
||||
if (err && typeof err === 'object') {
|
||||
const maybe = err as {
|
||||
response?: { data?: { message?: unknown } }
|
||||
message?: unknown
|
||||
}
|
||||
const serverMessage = maybe.response?.data?.message
|
||||
if (typeof serverMessage === 'string' && serverMessage) return serverMessage
|
||||
if (typeof maybe.message === 'string' && maybe.message) return maybe.message
|
||||
}
|
||||
return String(err)
|
||||
}
|
||||
|
||||
function mergeProgress(
|
||||
aggregate: ImportProgress,
|
||||
fileProgress: ImportProgress,
|
||||
label: string | null
|
||||
): ImportProgress {
|
||||
return {
|
||||
...aggregate,
|
||||
import_id: fileProgress.import_id || aggregate.import_id,
|
||||
format: fileProgress.format || aggregate.format,
|
||||
total: aggregate.total + fileProgress.total,
|
||||
success: aggregate.success + fileProgress.success,
|
||||
duplicates: aggregate.duplicates + fileProgress.duplicates,
|
||||
failed: aggregate.failed + fileProgress.failed,
|
||||
failed_details: [
|
||||
...aggregate.failed_details,
|
||||
...fileProgress.failed_details.map((d) =>
|
||||
label ? { ...d, error_message: `${label}: ${d.error_message}` } : d
|
||||
),
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
function fileFailure(fileIndex: number, message: string): ImportProgress {
|
||||
return {
|
||||
...emptyProgress(),
|
||||
status: 'Failed',
|
||||
total: 1,
|
||||
failed: 1,
|
||||
failed_details: [{ index: fileIndex, error_message: message }],
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForTerminal(
|
||||
initial: ImportProgress,
|
||||
getProgress: (importId: string) => Promise<ImportProgress>,
|
||||
pollIntervalMs: number,
|
||||
signal: AbortSignal | undefined,
|
||||
onTick: (progress: ImportProgress) => void
|
||||
): Promise<ImportProgress> {
|
||||
let current = initial
|
||||
let consecutiveErrors = 0
|
||||
while (!isTerminal(current.status)) {
|
||||
await sleep(pollIntervalMs)
|
||||
if (signal?.aborted) return current
|
||||
try {
|
||||
current = await getProgress(initial.import_id)
|
||||
consecutiveErrors = 0
|
||||
onTick(current)
|
||||
} catch (err) {
|
||||
consecutiveErrors++
|
||||
if (consecutiveErrors > MAX_POLL_ERRORS) {
|
||||
throw new Error(
|
||||
`lost track of import progress (the import may still be running, check import history): ${errorMessage(err)}`
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
return current
|
||||
}
|
||||
|
||||
export async function importFiles(
|
||||
files: File[],
|
||||
deps: ImportFilesDeps
|
||||
): Promise<ImportProgress> {
|
||||
const { upload, getProgress, onUploadPct, onProgress, onPhase, signal } = deps
|
||||
const pollIntervalMs = deps.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS
|
||||
const totalBytes = files.reduce((sum, f) => sum + f.size, 0)
|
||||
const label = files.length > 1 ? (file: File) => file.name : () => null
|
||||
|
||||
let aggregate = emptyProgress()
|
||||
let uploadedBytes = 0
|
||||
let transportFailures = 0
|
||||
let sawFailure = false
|
||||
|
||||
for (const [index, file] of files.entries()) {
|
||||
if (signal?.aborted) break
|
||||
|
||||
const startBytes = uploadedBytes
|
||||
const reportPct = (filePct: number) => {
|
||||
if (totalBytes === 0) return
|
||||
const bytes = startBytes + (filePct / 100) * file.size
|
||||
onUploadPct(Math.min(100, Math.round((bytes / totalBytes) * 100)))
|
||||
}
|
||||
|
||||
onPhase('uploading')
|
||||
let initial: ImportProgress
|
||||
try {
|
||||
initial = await upload(file, reportPct)
|
||||
} catch (err) {
|
||||
transportFailures++
|
||||
sawFailure = true
|
||||
aggregate = mergeProgress(
|
||||
aggregate,
|
||||
fileFailure(index, `${file.name}: ${errorMessage(err)}`),
|
||||
null
|
||||
)
|
||||
uploadedBytes = startBytes + file.size
|
||||
reportPct(100)
|
||||
onProgress(aggregate)
|
||||
continue
|
||||
}
|
||||
uploadedBytes = startBytes + file.size
|
||||
reportPct(100)
|
||||
|
||||
onPhase('processing')
|
||||
let final: ImportProgress
|
||||
try {
|
||||
final = await waitForTerminal(
|
||||
initial,
|
||||
getProgress,
|
||||
pollIntervalMs,
|
||||
signal,
|
||||
(current) => onProgress(mergeProgress(aggregate, current, label(file)))
|
||||
)
|
||||
} catch (err) {
|
||||
sawFailure = true
|
||||
aggregate = mergeProgress(
|
||||
aggregate,
|
||||
fileFailure(index, `${file.name}: ${errorMessage(err)}`),
|
||||
null
|
||||
)
|
||||
onProgress(aggregate)
|
||||
continue
|
||||
}
|
||||
if (final.status === 'Failed' || final.failed > 0) sawFailure = true
|
||||
aggregate = mergeProgress(aggregate, final, label(file))
|
||||
onProgress(aggregate)
|
||||
}
|
||||
|
||||
if (files.length > 0 && transportFailures === files.length) {
|
||||
throw new Error(
|
||||
aggregate.failed_details[0]?.error_message ?? 'Upload failed'
|
||||
)
|
||||
}
|
||||
|
||||
const result: ImportProgress = {
|
||||
...aggregate,
|
||||
status: aggregate.success === 0 && sawFailure ? 'Failed' : 'Completed',
|
||||
}
|
||||
onProgress(result)
|
||||
return result
|
||||
}
|
||||
@@ -9,7 +9,7 @@ import { useMutation, useQuery } from '@tanstack/react-query';
|
||||
import {
|
||||
Upload, FileText, X, CheckCircle2, AlertTriangle,
|
||||
Sparkles, PenLine, ListTree, ChevronsUpDown, Check,
|
||||
Clock, ChevronRight,
|
||||
Clock, ChevronRight, Copy,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -45,6 +45,7 @@ import {
|
||||
import { get_system_configurations } from '@/api/system/api';
|
||||
import { list_mailboxes } from '@/api/mailbox/api';
|
||||
import { extractFolderHint, type FolderHint } from './folder-hint';
|
||||
import { importFiles, errorMessage } from './import-files';
|
||||
|
||||
const MAX_EML = 100 * 1024 * 1024; // 100 MB (hardcoded)
|
||||
const DEFAULT_MAX_MBOX = 1024 * 1024 * 1024; // 1 GB (fallback; actual limit from server settings)
|
||||
@@ -101,10 +102,9 @@ export default function ImportPage() {
|
||||
|
||||
const [accountId, setAccountId] = useState<string>('');
|
||||
const [folderMode, setFolderMode] = useState<FolderMode>('');
|
||||
const [folder, setFolder] = useState('INBOX');
|
||||
const [folder, setFolder] = useState('inbox');
|
||||
const [files, setFiles] = useState<QueuedFile[]>([]);
|
||||
const [dragging, setDragging] = useState(false);
|
||||
// const [importId, setImportId] = useState<string | null>(null);
|
||||
const [progress, setProgress] = useState<ImportProgress | null>(null);
|
||||
const [uploadPct, setUploadPct] = useState(0);
|
||||
const [phase, setPhase] = useState<'idle' | 'uploading' | 'processing' | 'done'>('idle');
|
||||
@@ -117,7 +117,14 @@ export default function ImportPage() {
|
||||
// Combobox state for account selection
|
||||
const [accountOpen, setAccountOpen] = useState(false);
|
||||
|
||||
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
|
||||
const processedCount = progress ? progress.success + progress.failed + progress.duplicates : 0;
|
||||
const processedPct = progress && progress.total > 0 ? (processedCount / progress.total) * 100 : 0;
|
||||
|
||||
useEffect(() => {
|
||||
return () => { abortRef.current?.abort(); };
|
||||
}, []);
|
||||
|
||||
const { data: accounts = [] } = useQuery({
|
||||
queryKey: ['nosync-accounts'],
|
||||
@@ -168,33 +175,6 @@ export default function ImportPage() {
|
||||
}
|
||||
})();
|
||||
|
||||
const startPolling = useCallback((id: string) => {
|
||||
if (pollRef.current) clearInterval(pollRef.current);
|
||||
let retries = 0;
|
||||
pollRef.current = setInterval(async () => {
|
||||
try {
|
||||
const p = await get_import_progress(id);
|
||||
setProgress(p);
|
||||
retries = 0;
|
||||
if (p.status === 'Completed' || p.status === 'Failed') {
|
||||
if (pollRef.current) clearInterval(pollRef.current);
|
||||
setPhase('done');
|
||||
refetchHistory();
|
||||
}
|
||||
} catch {
|
||||
retries++;
|
||||
if (retries > 5) {
|
||||
if (pollRef.current) clearInterval(pollRef.current);
|
||||
setPhase('idle');
|
||||
}
|
||||
}
|
||||
}, 1000);
|
||||
}, [refetchHistory]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => { if (pollRef.current) clearInterval(pollRef.current); };
|
||||
}, []);
|
||||
|
||||
const handleFiles = useCallback(async (newFiles: FileList | File[]) => {
|
||||
const arr = Array.from(newFiles) as File[];
|
||||
const queued: QueuedFile[] = arr.map((f) => {
|
||||
@@ -209,7 +189,6 @@ export default function ImportPage() {
|
||||
setFiles(queued);
|
||||
setPhase('idle');
|
||||
setProgress(null);
|
||||
//setImportId(null);
|
||||
|
||||
// Extract folder hint from the first valid file.
|
||||
// PST files are binary (OLE2) — headers can't be extracted in-browser.
|
||||
@@ -277,26 +256,32 @@ export default function ImportPage() {
|
||||
const importMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!accountId || !files.length) return;
|
||||
const file = files[0].file;
|
||||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
setPhase('uploading');
|
||||
setUploadPct(0);
|
||||
const result = await upload_import(
|
||||
Number(accountId),
|
||||
effectiveFolder,
|
||||
file.name,
|
||||
file,
|
||||
(pct) => setUploadPct(pct),
|
||||
setProgress(null);
|
||||
await importFiles(
|
||||
files.map((q) => q.file),
|
||||
{
|
||||
upload: (file, onPct) =>
|
||||
upload_import(Number(accountId), effectiveFolder, file.name, file, onPct),
|
||||
getProgress: get_import_progress,
|
||||
onUploadPct: setUploadPct,
|
||||
onProgress: setProgress,
|
||||
onPhase: setPhase,
|
||||
signal: controller.signal,
|
||||
},
|
||||
);
|
||||
//setImportId(result.import_id);
|
||||
setProgress(result);
|
||||
setPhase('processing');
|
||||
startPolling(result.import_id);
|
||||
setPhase('done');
|
||||
refetchHistory();
|
||||
},
|
||||
onError: (err: any) => {
|
||||
onError: (err: unknown) => {
|
||||
setPhase('idle');
|
||||
setProgress(null);
|
||||
toast({
|
||||
title: t('common.failed'),
|
||||
description: err?.response?.data?.message || err.message,
|
||||
description: errorMessage(err),
|
||||
variant: 'destructive',
|
||||
});
|
||||
},
|
||||
@@ -381,196 +366,20 @@ export default function ImportPage() {
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
{!accountId && (
|
||||
<p className="text-xs text-destructive mt-1.5 flex items-center gap-1">
|
||||
<AlertTriangle className="h-3 w-3" />
|
||||
{t('import.selectAccountRequired', 'Please select a target account before importing.')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Step 2: Folder determination mode */}
|
||||
{/* Step 2: File upload */}
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm font-medium">
|
||||
{isPstSelected
|
||||
? t('import.folderStructure', '2. Folder structure')
|
||||
: t('import.folderMethod', '2. Choose folder method')}
|
||||
</CardTitle>
|
||||
<CardDescription className="text-xs">
|
||||
{isPstSelected
|
||||
? t('import.pstFolderDesc', 'The PST file contains its own folder structure (e.g. Inbox, Sent Items, etc.). Folders will be automatically created during import.')
|
||||
: files.length === 0
|
||||
? t('import.selectFileFirst', 'Select a file first to determine available options.')
|
||||
: t('import.folderMethodDesc', 'How should the target mail folder be determined?')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{!isPstSelected && (
|
||||
<RadioGroup
|
||||
value={folderMode}
|
||||
onValueChange={(v) => handleModeChange(v as FolderMode)}
|
||||
className="gap-3"
|
||||
>
|
||||
{/* Mode 1: Auto-detect from headers */}
|
||||
<label
|
||||
className={cn(
|
||||
'flex items-start gap-3 rounded-lg border p-3 cursor-pointer transition-colors',
|
||||
folderMode === 'header'
|
||||
? 'border-primary bg-primary/5'
|
||||
: 'border-border hover:bg-muted/50',
|
||||
)}
|
||||
>
|
||||
<RadioGroupItem value="header" id="mode-header" className="mt-0.5" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<Sparkles className="h-4 w-4 text-primary" />
|
||||
<span className="text-sm font-medium">
|
||||
{t('import.modeHeader', 'Auto-detect from email headers')}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{t('import.modeHeaderDesc', 'Read X-Gmail-Labels / X-Bichon-Metadata from the uploaded file. Falls back to filename.')}
|
||||
</p>
|
||||
{folderMode === 'header' && (
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<Badge variant="secondary" className="text-xs font-normal">
|
||||
{folderHint
|
||||
? t('import.detectedFolder', 'Detected') + ': ' + headerFolder
|
||||
: t('import.noFileYet', 'No file selected yet')}
|
||||
</Badge>
|
||||
{folderHint && (
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
({t('import.source')}: {folderHintLabel(folderHint)})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</label>
|
||||
|
||||
{/* Mode 2: Pick from existing mailboxes */}
|
||||
<label
|
||||
className={cn(
|
||||
'flex items-start gap-3 rounded-lg border p-3 cursor-pointer transition-colors',
|
||||
folderMode === 'existing'
|
||||
? 'border-primary bg-primary/5'
|
||||
: 'border-border hover:bg-muted/50',
|
||||
!accountId && 'opacity-50 pointer-events-none',
|
||||
)}
|
||||
>
|
||||
<RadioGroupItem value="existing" id="mode-existing" className="mt-0.5" disabled={!accountId} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<ListTree className="h-4 w-4 text-primary" />
|
||||
<span className="text-sm font-medium">
|
||||
{t('import.modeExisting', 'Choose from existing mailboxes')}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{t('import.modeExistingDesc', 'Select one of the mailboxes already present in this account.')}
|
||||
</p>
|
||||
{folderMode === 'existing' && (
|
||||
<div className="mt-2">
|
||||
{mailboxes.length === 0 ? (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{accountId
|
||||
? t('import.noMailboxes', 'No mailboxes found in this account.')
|
||||
: t('import.selectAccountFirst', 'Select an account first.')}
|
||||
</span>
|
||||
) : (
|
||||
<Popover open={mailboxOpen} onOpenChange={setMailboxOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
className="h-8 justify-between text-xs max-w-xs w-full"
|
||||
>
|
||||
<span className="truncate">
|
||||
{folder || t('import.selectMailbox', 'Select a mailbox...')}
|
||||
</span>
|
||||
<ChevronsUpDown className="ml-2 h-3.5 w-3.5 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[280px] p-0" align="start">
|
||||
<Command>
|
||||
<CommandInput
|
||||
placeholder={t('import.searchMailbox', 'Search mailboxes...')}
|
||||
className="h-9 text-xs"
|
||||
/>
|
||||
<CommandList>
|
||||
<CommandEmpty>
|
||||
{t('import.noMailboxFound', 'No mailbox found.')}
|
||||
</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{mailboxes.map((mb) => (
|
||||
<CommandItem
|
||||
key={mb.id}
|
||||
value={mb.name}
|
||||
onSelect={(value) => {
|
||||
setFolder(value);
|
||||
setMailboxOpen(false);
|
||||
}}
|
||||
className='text-xs'
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
'h-4 w-4',
|
||||
folder === mb.name ? 'opacity-100' : 'opacity-0',
|
||||
)}
|
||||
/>
|
||||
{mb.name}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</label>
|
||||
|
||||
{/* Mode 3: Manual input */}
|
||||
<label
|
||||
className={cn(
|
||||
'flex items-start gap-3 rounded-lg border p-3 cursor-pointer transition-colors',
|
||||
folderMode === 'custom'
|
||||
? 'border-primary bg-primary/5'
|
||||
: 'border-border hover:bg-muted/50',
|
||||
)}
|
||||
>
|
||||
<RadioGroupItem value="custom" id="mode-custom" className="mt-0.5" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<PenLine className="h-4 w-4 text-primary" />
|
||||
<span className="text-sm font-medium">
|
||||
{t('import.modeCustom', 'Enter a custom folder name')}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{t('import.modeCustomDesc', 'Manually type the target mail folder name.')}
|
||||
</p>
|
||||
{folderMode === 'custom' && (
|
||||
<div className="mt-2">
|
||||
<Input
|
||||
className="h-8 text-xs max-w-xs"
|
||||
value={folder}
|
||||
onChange={(e) => setFolder(e.target.value)}
|
||||
placeholder="INBOX"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</label>
|
||||
</RadioGroup>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Step 3: File upload */}
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm font-medium">
|
||||
{t('import.chooseFiles', '3. Choose files')}
|
||||
{t('import.chooseFiles', '2. Choose files')}
|
||||
</CardTitle>
|
||||
<CardDescription className="text-xs">
|
||||
{t('import.limits', {
|
||||
@@ -621,7 +430,7 @@ export default function ImportPage() {
|
||||
)}
|
||||
>
|
||||
<FileText className="h-4 w-4 shrink-0" />
|
||||
<span className="flex-1 truncate">{qf.file.name}</span>
|
||||
<span className="text-xs flex-1 truncate">{qf.file.name}</span>
|
||||
<span className={cn('text-xs shrink-0', qf.sizeOk && qf.typeOk ? 'text-muted-foreground' : 'font-medium')}>
|
||||
{formatSize(qf.file.size)}
|
||||
</span>
|
||||
@@ -649,9 +458,194 @@ export default function ImportPage() {
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{files.length === 0 && phase === 'idle' && (
|
||||
<div className="mt-3 text-xs text-destructive flex items-center gap-1.5">
|
||||
<AlertTriangle className="h-3.5 w-3.5" />
|
||||
<span>{t('import.noFilesSelected')}</span>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
{/* Step 3: Folder determination mode */}
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm font-medium">
|
||||
{isPstSelected
|
||||
? t('import.folderStructure', '3. Folder structure')
|
||||
: t('import.folderMethod', '3. Choose folder method')}
|
||||
</CardTitle>
|
||||
<CardDescription className="text-xs">
|
||||
{isPstSelected
|
||||
? t('import.pstFolderDesc', 'The PST file contains its own folder structure (e.g. Inbox, Sent Items, etc.). Folders will be automatically created during import.')
|
||||
: files.length === 0
|
||||
? t('import.selectFileFirst', 'Select a file first to determine available options.')
|
||||
: t('import.folderMethodDesc', 'How should the target mail folder be determined?')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{!isPstSelected && (
|
||||
<RadioGroup
|
||||
value={folderMode}
|
||||
onValueChange={(v) => handleModeChange(v as FolderMode)}
|
||||
className="gap-3"
|
||||
>
|
||||
{/* Mode 1: Auto-detect from headers */}
|
||||
<label
|
||||
className={cn(
|
||||
'flex items-start gap-3 rounded-lg border p-3 cursor-pointer transition-colors',
|
||||
folderMode === 'header'
|
||||
? 'border-primary bg-primary/5'
|
||||
: 'border-border hover:bg-muted/50',
|
||||
)}
|
||||
>
|
||||
<RadioGroupItem value="header" id="mode-header" className="mt-0.5" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<Sparkles className="h-4 w-4 text-primary" />
|
||||
<span className="text-xs font-medium">
|
||||
{t('import.modeHeader', 'Auto-detect from email headers')}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{t('import.modeHeaderDesc', 'Read X-Gmail-Labels / X-Bichon-Metadata from the uploaded file. Falls back to filename.')}
|
||||
</p>
|
||||
{folderMode === 'header' && (
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<Badge variant="secondary" className="text-xs font-normal">
|
||||
{folderHint
|
||||
? t('import.detectedFolder', 'Detected') + ': ' + headerFolder
|
||||
: t('import.noFileYet', 'No file selected yet')}
|
||||
</Badge>
|
||||
{folderHint && (
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
({t('import.source')}: {folderHintLabel(folderHint)}{files.length > 1 && `, ${folderHint.fileName}`})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</label>
|
||||
|
||||
{/* Mode 2: Pick from existing mailboxes */}
|
||||
<label
|
||||
className={cn(
|
||||
'flex items-start gap-3 rounded-lg border p-3 cursor-pointer transition-colors',
|
||||
folderMode === 'existing'
|
||||
? 'border-primary bg-primary/5'
|
||||
: 'border-border hover:bg-muted/50',
|
||||
!accountId && 'opacity-50 pointer-events-none',
|
||||
)}
|
||||
>
|
||||
<RadioGroupItem value="existing" id="mode-existing" className="mt-0.5" disabled={!accountId} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<ListTree className="h-4 w-4 text-primary" />
|
||||
<span className="text-xs font-medium">
|
||||
{t('import.modeExisting', 'Choose from existing mailboxes')}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{t('import.modeExistingDesc', 'Select one of the mailboxes already present in this account.')}
|
||||
</p>
|
||||
{folderMode === 'existing' && (
|
||||
<div className="mt-2">
|
||||
{mailboxes.length === 0 ? (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{accountId
|
||||
? t('import.noMailboxes', 'No mailboxes found in this account.')
|
||||
: t('import.selectAccountFirst', 'Select an account first.')}
|
||||
</span>
|
||||
) : (
|
||||
<Popover open={mailboxOpen} onOpenChange={setMailboxOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
className="h-8 justify-between text-xs max-w-xs w-full"
|
||||
>
|
||||
<span className="truncate">
|
||||
{folder || t('import.selectMailbox', 'Select a mailbox...')}
|
||||
</span>
|
||||
<ChevronsUpDown className="ml-2 h-3.5 w-3.5 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[280px] p-0" align="start">
|
||||
<Command>
|
||||
<CommandInput
|
||||
placeholder={t('import.searchMailbox', 'Search mailboxes...')}
|
||||
className="h-9 text-xs"
|
||||
/>
|
||||
<CommandList>
|
||||
<CommandEmpty>
|
||||
{t('import.noMailboxFound', 'No mailbox found.')}
|
||||
</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{mailboxes.map((mb) => (
|
||||
<CommandItem
|
||||
key={mb.id}
|
||||
value={mb.name}
|
||||
onSelect={(value) => {
|
||||
setFolder(value);
|
||||
setMailboxOpen(false);
|
||||
}}
|
||||
className='text-xs'
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
'h-4 w-4',
|
||||
folder === mb.name ? 'opacity-100' : 'opacity-0',
|
||||
)}
|
||||
/>
|
||||
{mb.name}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</label>
|
||||
|
||||
{/* Mode 3: Manual input */}
|
||||
<label
|
||||
className={cn(
|
||||
'flex items-start gap-3 rounded-lg border p-3 cursor-pointer transition-colors',
|
||||
folderMode === 'custom'
|
||||
? 'border-primary bg-primary/5'
|
||||
: 'border-border hover:bg-muted/50',
|
||||
)}
|
||||
>
|
||||
<RadioGroupItem value="custom" id="mode-custom" className="mt-0.5" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<PenLine className="h-4 w-4 text-primary" />
|
||||
<span className="text-xs font-medium">
|
||||
{t('import.modeCustom', 'Enter a custom folder name')}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{t('import.modeCustomDesc', 'Manually type the target mail folder name.')}
|
||||
</p>
|
||||
{folderMode === 'custom' && (
|
||||
<div className="mt-2 text-xs">
|
||||
<Input
|
||||
className="h-8 text-xs max-w-xs"
|
||||
value={folder}
|
||||
onChange={(e) => setFolder(e.target.value)}
|
||||
placeholder="inbox"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</label>
|
||||
</RadioGroup>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
{/* Step 4: Progress & Results */}
|
||||
{(phase !== 'idle' || progress) && (
|
||||
<Card>
|
||||
@@ -677,18 +671,11 @@ export default function ImportPage() {
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex justify-between text-xs text-muted-foreground">
|
||||
<span>
|
||||
{t('import.processed', { current: progress.success + progress.failed, total: progress.total })}
|
||||
</span>
|
||||
<span>
|
||||
{progress.total > 0
|
||||
? Math.round(((progress.success + progress.failed) / progress.total) * 100)
|
||||
: 0}%
|
||||
{t('import.processed', { current: processedCount, total: progress.total })}
|
||||
</span>
|
||||
<span>{Math.round(processedPct)}%</span>
|
||||
</div>
|
||||
<Progress
|
||||
value={progress.total > 0 ? ((progress.success + progress.failed) / progress.total) * 100 : 0}
|
||||
className="h-2"
|
||||
/>
|
||||
<Progress value={processedPct} className="h-2" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -702,6 +689,12 @@ export default function ImportPage() {
|
||||
<AlertTriangle className="h-3.5 w-3.5 text-amber-600" />
|
||||
{t('import.failedCount', { count: progress.failed })}
|
||||
</span>
|
||||
{progress.duplicates > 0 && (
|
||||
<span className="flex items-center gap-1" title={t('import.duplicateCountHint')}>
|
||||
<Copy className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
{t('import.duplicateCount', { count: progress.duplicates })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -725,27 +718,43 @@ export default function ImportPage() {
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Import button */}
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{isPstSelected
|
||||
? t('import.pstFolders', 'PST folder structure will be preserved during import')
|
||||
: (<>{t('import.willImportTo', 'Will import to')}: <span className="font-medium text-foreground">{effectiveFolder}</span></>)}
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{isPstSelected
|
||||
? t('import.pstFolders', 'PST folder structure will be preserved during import')
|
||||
: (<>{t('import.willImportTo', 'Will import to')}: <span className="font-medium text-foreground">{effectiveFolder}</span>{files.length > 1 && <> · {t('import.fileCount', { count: files.length })}</>}</>)}
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => importMutation.mutate()}
|
||||
disabled={!canImport || importMutation.isPending}
|
||||
className="gap-2"
|
||||
>
|
||||
{importMutation.isPending ? (
|
||||
<Upload className="h-4 w-4 animate-pulse" />
|
||||
) : (
|
||||
<Upload className="h-4 w-4" />
|
||||
)}
|
||||
{t('import.startImport', 'Import')}
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => importMutation.mutate()}
|
||||
disabled={!canImport || importMutation.isPending}
|
||||
className="gap-2"
|
||||
>
|
||||
{importMutation.isPending ? (
|
||||
<Upload className="h-4 w-4 animate-pulse" />
|
||||
) : (
|
||||
<Upload className="h-4 w-4" />
|
||||
)}
|
||||
{t('import.startImport', 'Import')}
|
||||
</Button>
|
||||
{(!accountId || files.length === 0) && (
|
||||
<div className="text-xs text-destructive flex items-center justify-end gap-1.5">
|
||||
<AlertTriangle className="h-3.5 w-3.5" />
|
||||
<span>
|
||||
{!accountId && !files.length && (
|
||||
t('import.selectAccountAndFiles', 'Please select a target account and files first.')
|
||||
)}
|
||||
{!accountId && files.length > 0 && (
|
||||
t('import.selectAccountRequired', 'Please select a target account first.')
|
||||
)}
|
||||
{accountId && files.length === 0 && (
|
||||
t('import.selectFilesRequired', 'Please select files to import.')
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Import history */}
|
||||
{history.length > 0 && (
|
||||
<CollapsibleHistory
|
||||
|
||||
284
web/src/features/license/index.tsx
Normal file
284
web/src/features/license/index.tsx
Normal file
@@ -0,0 +1,284 @@
|
||||
//
|
||||
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
|
||||
//
|
||||
// This file is part of the Bichon Email Archiving Project
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { AxiosError } from 'axios'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Copy, FileUp, Loader2 } from 'lucide-react'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { FixedHeader } from '@/components/layout/fixed-header'
|
||||
import { Main } from '@/components/layout/main'
|
||||
import { useEdition } from '@/hooks/use-edition'
|
||||
import { useCurrentUser } from '@/hooks/use-current-user'
|
||||
import { useToast } from '@/hooks/use-toast'
|
||||
import {
|
||||
get_license_status,
|
||||
upload_license,
|
||||
type LicenseStatusResponse,
|
||||
} from '@/api/license/api'
|
||||
|
||||
function formatEpoch(ts?: string | null): string {
|
||||
if (!ts) return '—'
|
||||
const n = Number(ts)
|
||||
if (!Number.isFinite(n)) return ts
|
||||
return new Date(n * 1000).toLocaleDateString()
|
||||
}
|
||||
|
||||
function formatEdition(edition?: string | null): string {
|
||||
if (!edition) return ''
|
||||
return edition.charAt(0).toUpperCase() + edition.slice(1)
|
||||
}
|
||||
|
||||
function InfoRow({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className='flex items-start justify-between gap-4 py-2'>
|
||||
<span className='text-sm text-muted-foreground'>{label}</span>
|
||||
<div className='flex flex-wrap items-center justify-end gap-1.5 text-right text-sm'>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function LicensePage() {
|
||||
const { t } = useTranslation()
|
||||
const { toast } = useToast()
|
||||
const queryClient = useQueryClient()
|
||||
const { isPro, features } = useEdition()
|
||||
const { require_any_permission } = useCurrentUser()
|
||||
const [licenseText, setLicenseText] = useState('')
|
||||
|
||||
const { data, isLoading, error } = useQuery<LicenseStatusResponse, AxiosError>({
|
||||
queryKey: ['license-status'],
|
||||
queryFn: get_license_status,
|
||||
retry: false,
|
||||
})
|
||||
|
||||
const upload = useMutation({
|
||||
mutationFn: upload_license,
|
||||
onSuccess: () => {
|
||||
toast({ title: t('license.uploadSuccess') })
|
||||
setLicenseText('')
|
||||
queryClient.invalidateQueries({ queryKey: ['license-status'] })
|
||||
},
|
||||
onError: (err: AxiosError<{ error?: string }>) => {
|
||||
toast({
|
||||
title: t('license.uploadFailed'),
|
||||
description: err.response?.data?.error ?? t('license.uploadFailedDesc'),
|
||||
variant: 'destructive',
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
const copyMachineId = async () => {
|
||||
if (!data?.machine_id) return
|
||||
try {
|
||||
await navigator.clipboard.writeText(data.machine_id)
|
||||
toast({ title: t('license.copied') })
|
||||
} catch {
|
||||
toast({ title: t('license.copyFailed'), variant: 'destructive' })
|
||||
}
|
||||
}
|
||||
|
||||
const onFilePicked = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => setLicenseText(String(reader.result ?? '').trim())
|
||||
reader.onerror = () => toast({ title: t('license.readFileFailed'), variant: 'destructive' })
|
||||
reader.readAsText(file)
|
||||
e.target.value = ''
|
||||
}
|
||||
|
||||
const licenseEnabled = features.includes('license')
|
||||
const canView = isPro && licenseEnabled && require_any_permission(['system:root', 'user:manage'])
|
||||
|
||||
if (!canView) {
|
||||
return (
|
||||
<>
|
||||
<FixedHeader />
|
||||
<Main>
|
||||
<div className='mx-auto w-full max-w-7xl px-4 py-16 text-center text-muted-foreground'>
|
||||
{t('license.forbidden', 'License management is available in the Pro edition only.')}
|
||||
</div>
|
||||
</Main>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const statusLabels: Record<string, string> = {
|
||||
valid: t('license.statusValid'),
|
||||
trial: t('license.statusTrial'),
|
||||
trial_expired: t('license.statusTrialExpired'),
|
||||
update_expired: t('license.statusUpdateExpired'),
|
||||
machine_mismatch: t('license.statusMachineMismatch'),
|
||||
invalid_signature: t('license.statusInvalid'),
|
||||
error: t('license.statusError'),
|
||||
}
|
||||
|
||||
const statusVariant: Record<string, 'default' | 'secondary' | 'destructive' | 'outline'> = {
|
||||
valid: 'default',
|
||||
trial: 'secondary',
|
||||
}
|
||||
|
||||
const status = data?.status ?? ''
|
||||
|
||||
return (
|
||||
<>
|
||||
<FixedHeader />
|
||||
<Main>
|
||||
<div className='mx-auto w-full max-w-7xl px-4'>
|
||||
<h1 className='mb-1 text-xl font-semibold'>{t('license.title')}</h1>
|
||||
<p className='mb-6 text-sm text-muted-foreground'>{t('license.description')}</p>
|
||||
|
||||
{isLoading && (
|
||||
<div className='flex h-40 items-center justify-center'>
|
||||
<Loader2 className='h-6 w-6 animate-spin' />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && !isLoading && (
|
||||
<div className='mb-6 rounded-md border border-destructive/50 p-4 text-sm text-destructive'>
|
||||
{t('license.loadFailed')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data && (
|
||||
<div className='grid gap-6 lg:grid-cols-2'>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('license.statusTitle')}</CardTitle>
|
||||
<CardDescription>{t('license.statusDesc')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className='divide-y'>
|
||||
<InfoRow label={t('license.status')}>
|
||||
<Badge variant={statusVariant[status] ?? 'destructive'}>
|
||||
{statusLabels[status] ?? status}
|
||||
</Badge>
|
||||
</InfoRow>
|
||||
<InfoRow label={t('license.edition')}>
|
||||
{data.edition ? (
|
||||
<Badge variant='outline'>{formatEdition(data.edition)}</Badge>
|
||||
) : (
|
||||
t('license.notAvailable')
|
||||
)}
|
||||
</InfoRow>
|
||||
<InfoRow label={t('license.licensee')}>
|
||||
{data.email ?? t('license.notAvailable')}
|
||||
</InfoRow>
|
||||
<InfoRow label={t('license.updatesUntil')}>
|
||||
{formatEpoch(data.updates_until)}
|
||||
</InfoRow>
|
||||
{data.days_remaining !== null && data.days_remaining !== undefined && (
|
||||
<InfoRow label={t('license.trialDays')}>
|
||||
{t('license.trialDaysRemaining', { days: data.days_remaining })}
|
||||
</InfoRow>
|
||||
)}
|
||||
<InfoRow label={t('license.accounts')}>
|
||||
{data.account_limit
|
||||
? t('license.accountsUsed', {
|
||||
used: data.accounts_used,
|
||||
limit: data.account_limit,
|
||||
})
|
||||
: t('license.notAvailable')}
|
||||
</InfoRow>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className='flex flex-col gap-6'>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('license.machineIdTitle')}</CardTitle>
|
||||
<CardDescription>{t('license.machineIdDesc')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className='flex items-center gap-2'>
|
||||
<code className='min-w-0 flex-1 break-all rounded-md bg-muted px-3 py-2 font-mono text-xs'>
|
||||
{data.machine_id || t('license.notAvailable')}
|
||||
</code>
|
||||
<Button
|
||||
variant='outline'
|
||||
size='icon'
|
||||
onClick={copyMachineId}
|
||||
title={t('license.copyMachineId')}
|
||||
disabled={!data.machine_id}
|
||||
>
|
||||
<Copy className='h-4 w-4' />
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('license.uploadTitle')}</CardTitle>
|
||||
<CardDescription>{t('license.uploadDesc')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className='flex flex-col gap-3'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Label
|
||||
htmlFor='license-file'
|
||||
className='inline-flex h-9 cursor-pointer items-center gap-2 rounded-md border border-input bg-background px-3 text-sm font-medium shadow-sm transition-colors hover:bg-accent'
|
||||
>
|
||||
<FileUp className='h-4 w-4' />
|
||||
{t('license.chooseFile')}
|
||||
<input
|
||||
id='license-file'
|
||||
type='file'
|
||||
accept='.jwt,.txt,text/plain,application/json'
|
||||
className='hidden'
|
||||
onChange={onFilePicked}
|
||||
/>
|
||||
</Label>
|
||||
</div>
|
||||
<Textarea
|
||||
value={licenseText}
|
||||
onChange={(e) => setLicenseText(e.target.value)}
|
||||
placeholder={t('license.pasteHere')}
|
||||
rows={5}
|
||||
className='font-mono text-xs'
|
||||
/>
|
||||
<Button
|
||||
onClick={() => upload.mutate(licenseText.trim())}
|
||||
disabled={!licenseText.trim() || upload.isPending}
|
||||
className='self-start'
|
||||
>
|
||||
{upload.isPending && <Loader2 className='mr-2 h-4 w-4 animate-spin' />}
|
||||
{upload.isPending ? t('license.uploading') : t('license.upload')}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Main>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -21,7 +21,7 @@ import { Card, CardContent } from '@/components/ui/card';
|
||||
import { FixedHeader } from '@/components/layout/fixed-header';
|
||||
import { Main } from '@/components/layout/main';
|
||||
import { useSearchMessages } from '@/hooks/use-search-messages';
|
||||
import { AttachmentListPagination } from '@/components/pagination';
|
||||
import { TablePagination } from '@/components/pagination';
|
||||
import React from 'react';
|
||||
import { EmailEnvelope } from '@/api';
|
||||
import { MailDisplayDrawer } from './mail-display-dialog';
|
||||
@@ -128,7 +128,7 @@ export default function EmailSearch() {
|
||||
setSortBy={setSortBy}
|
||||
setSortOrder={setSortOrder}
|
||||
/>
|
||||
{total > 0 && <AttachmentListPagination
|
||||
{total > 0 && <TablePagination
|
||||
totalItems={total}
|
||||
hasNextPage={() => page < totalPages}
|
||||
pageIndex={page - 1}
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { Loader, Download, Trash2, MessageSquareMore, FileText, FileImage, FileVideo, FileArchive, FileSpreadsheet, FileCode, FileIcon, FileAudio, Upload, ShieldCheck, Pencil } from 'lucide-react';
|
||||
|
||||
@@ -177,7 +177,11 @@ export function MailMessageView({
|
||||
setBlockRemote(true);
|
||||
}, [envelope.id]);
|
||||
|
||||
const loadedKeyRef = useRef('');
|
||||
useEffect(() => {
|
||||
const key = `${envelope.account_id}:${envelope.id}:${blockRemote}`;
|
||||
if (loadedKeyRef.current === key) return;
|
||||
loadedKeyRef.current = key;
|
||||
setLoading(true);
|
||||
loadMessageMutation.mutate();
|
||||
}, [envelope.id, blockRemote]);
|
||||
|
||||
@@ -98,6 +98,7 @@
|
||||
"allMailSkipped": "تم تحديد المجلدات القياسية. تم تخطي 'جميع رسائل البريد' لتجنب التكرارات.",
|
||||
"areYouSureYouWantTo": "هل أنت متأكد من أنك تريد {{action}} هذا الحساب؟",
|
||||
"auth": "المصادقة",
|
||||
"authPassword": "كلمة مرور المصادقة",
|
||||
"authType": "نوع_المصادقة",
|
||||
"autoConfiguring": "جاري التكوين التلقائي…",
|
||||
"autoDiscover": "اكتشاف إعدادات الخادم تلقائيًا",
|
||||
@@ -116,6 +117,7 @@
|
||||
"clickSaveWhenDone": "انقر على حفظ عند الانتهاء.",
|
||||
"continue": "متابعة",
|
||||
"createdAt": "تاريخ الإنشاء",
|
||||
"creating": "جاري إنشاء الحساب...",
|
||||
"creationFailed": "فشل الإنشاء، يرجى المحاولة مرة أخرى لاحقًا",
|
||||
"cronAdvanced": "تعبير متقدم",
|
||||
"cronDaily": "يومياً",
|
||||
@@ -333,18 +335,26 @@
|
||||
"selectedMailboxes": "صناديق البريد المحددة",
|
||||
"serverConfiguration": "تكوين الخادم (IMAP)",
|
||||
"settings": {
|
||||
"backToAccounts": "العودة إلى الحسابات",
|
||||
"download": "تنزيل",
|
||||
"downloadDesc": "تكوين وقت وكيفية جلب رسائل البريد الإلكتروني من الخادم.",
|
||||
"filters": "الفلاتر",
|
||||
"filtersDesc": "التحكم في الرسائل التي يتم أرشفتها. عند تعطيل الفلترة، يتم حفظ جميع الرسائل.",
|
||||
"general": "عام",
|
||||
"generalDesc": "معلومات الحساب الأساسية والحالة.",
|
||||
"loading": "جاري تحميل الإعدادات...",
|
||||
"newAccount": "حساب جديد",
|
||||
"performance": "الأداء",
|
||||
"reset": "إعادة ضبط الإعدادات",
|
||||
"save": "حفظ الإعدادات",
|
||||
"saved": "تم الحفظ",
|
||||
"savedDesc": "تم حفظ إعدادات الحساب بنجاح.",
|
||||
"saving": "جاري حفظ الإعدادات...",
|
||||
"schedule": "جدول المزامنة",
|
||||
"scope": "نطاق المزامنة",
|
||||
"server": "الخادم",
|
||||
"serverDesc": "إعدادات اتصال IMAP والمصادقة."
|
||||
"serverDesc": "إعدادات اتصال IMAP والمصادقة.",
|
||||
"settings": "إعدادات الحساب"
|
||||
},
|
||||
"since": "منذ",
|
||||
"sinceFixed": "منذ تاريخ محدد",
|
||||
@@ -470,6 +480,7 @@
|
||||
"downloading": "جاري التنزيل...",
|
||||
"emailMessageNotFound": "تعذر العثور على رسالة البريد الإلكتروني الأصلية. ربما تم حذفها.",
|
||||
"name": "اسم الملف",
|
||||
"preview": "معاينة المرفق",
|
||||
"search_input_placeholder": "بحث عن المرفقات (استخدم \" \" للبحث عن عبارة)",
|
||||
"sender": "المرسل",
|
||||
"sender_with_count": "المرسل ({{count}})",
|
||||
@@ -487,6 +498,70 @@
|
||||
"zoomIn": "تكبير",
|
||||
"zoomOut": "تصغير"
|
||||
},
|
||||
"audit": {
|
||||
"account": "الحساب",
|
||||
"accountPlaceholder": "اختر الحساب",
|
||||
"allAccounts": "جميع الحسابات",
|
||||
"allTypes": "جميع الأنواع",
|
||||
"allUsers": "جميع المستخدمين",
|
||||
"apply": "تطبيق",
|
||||
"detail": "التفاصيل",
|
||||
"empty": "لم يتم العثور على أحداث تدقيق",
|
||||
"endDate": "تاريخ الانتهاء",
|
||||
"eventType": "نوع الحدث",
|
||||
"eventTypes": {
|
||||
"accessTokenCreated": "إنشاء رمز الوصول",
|
||||
"accessTokenRemoved": "إزالة رمز الوصول",
|
||||
"accountCreated": "إنشاء حساب",
|
||||
"accountDownloadStarted": "بدء مزامنة الحساب",
|
||||
"accountDownloadStopped": "إيقاف مزامنة الحساب",
|
||||
"accountRemoved": "إزالة حساب",
|
||||
"accountRoleAssigned": "تعيين صلاحية الحساب",
|
||||
"accountUpdated": "تحديث حساب",
|
||||
"attachmentDownloaded": "تنزيل المرفق",
|
||||
"attachmentPreviewed": "معاينة المرفق",
|
||||
"attachmentTagged": "تعديل علامات المرفق",
|
||||
"emailDeleted": "حذف البريد الإلكتروني",
|
||||
"emailExported": "تصدير البريد الإلكتروني",
|
||||
"emailRestored": "استعادة البريد الإلكتروني",
|
||||
"emailTagged": "تعديل علامات البريد",
|
||||
"emailViewed": "عرض البريد الإلكتروني",
|
||||
"importPerformed": "تنفيذ الاستيراد",
|
||||
"licenseUploaded": "تحميل الترخيص",
|
||||
"mailboxRemoved": "إزالة صندوق البريد",
|
||||
"oauth2Created": "إنشاء إعدادات OAuth2",
|
||||
"oauth2Removed": "إزالة إعدادات OAuth2",
|
||||
"oauth2TokenStored": "حفظ رمز OAuth2",
|
||||
"oauth2Updated": "تحديث إعدادات OAuth2",
|
||||
"proxyCreated": "إنشاء وكيل",
|
||||
"proxyRemoved": "إزالة وكيل",
|
||||
"proxyUpdated": "تحديث وكيل",
|
||||
"roleCreated": "إنشاء دور",
|
||||
"roleRemoved": "إزالة دور",
|
||||
"roleUpdated": "تحديث دور",
|
||||
"searchPerformed": "تنفيذ البحث",
|
||||
"settingsChanged": "تغيير الإعدادات",
|
||||
"ssoLogin": "تسجيل دخول SSO",
|
||||
"ssoLogout": "تسجيل خروج SSO",
|
||||
"userCreated": "إنشاء مستخدم",
|
||||
"userLogin": "تسجيل دخول المستخدم",
|
||||
"userRemoved": "إزالة مستخدم",
|
||||
"userUpdated": "تحديث مستخدم"
|
||||
},
|
||||
"forbidden": "سجل التدقيق متاح فقط في الإصدار الاحترافي (Pro).",
|
||||
"hideDetails": "إخفاء التفاصيل",
|
||||
"ip": "عنوان IP",
|
||||
"loading": "جاري التحميل...",
|
||||
"noAccounts": "لم يتم العثور على حسابات",
|
||||
"noUsers": "لم يتم العثور على مستخدمين",
|
||||
"reset": "إعادة ضبط",
|
||||
"showDetails": "إظهار التفاصيل",
|
||||
"startDate": "تاريخ البدء",
|
||||
"time": "الوقت",
|
||||
"title": "سجل التدقيق",
|
||||
"user": "المستخدم",
|
||||
"userPlaceholder": "اسم المستخدم"
|
||||
},
|
||||
"auth": {
|
||||
"areYouSureYouWantToLogOut": "هل أنت متأكد أنك تريد تسجيل الخروج؟",
|
||||
"invalidPassword": "كلمة مرور غير صالحة. الرجاء المحاولة مرة أخرى.",
|
||||
@@ -497,6 +572,7 @@
|
||||
"sessionExpired": "انتهت صلاحية الجلسة!",
|
||||
"sessionExpiredDesc": "انتهت جلستك بسبب عدم النشاط. يرجى تسجيل الدخول مرة أخرى للمتابعة.",
|
||||
"somethingWentWrong": "حدث خطأ ما",
|
||||
"ssoLogin": "تسجيل الدخول عبر SSO",
|
||||
"username": "اسم المستخدم",
|
||||
"welcome": "مرحبًا بك في بيشون",
|
||||
"youWillNeedToLogInAgain": "ستحتاج إلى تسجيل الدخول مرة أخرى للوصول إلى حسابك."
|
||||
@@ -657,19 +733,22 @@
|
||||
},
|
||||
"import": {
|
||||
"account": "الحساب",
|
||||
"chooseFiles": "3. اختر الملفات",
|
||||
"chooseFiles": "2. اختر الملفات",
|
||||
"completed": "اكتمل الاستيراد",
|
||||
"description": "استيراد ملفات البريد إلى حساب محلي (NoSync). للملفات الكبيرة، استخدم CLI.",
|
||||
"detectedFolder": "مكتشف",
|
||||
"detectedFrom": "مكتشف من",
|
||||
"dropHere": "أفلت ملفات .eml / .mbox / .pst هنا",
|
||||
"duplicateCount": "تم تخطي {{count}} من التكرارات",
|
||||
"duplicateCountHint": "هذه الرسائل مؤرشفة بالفعل",
|
||||
"failed": "فشل الاستيراد",
|
||||
"failedCount": "{{count}} فشل",
|
||||
"failedDetails": "العناصر الفاشلة",
|
||||
"fileCount": "{{count}} ملف",
|
||||
"folder": "المجلد",
|
||||
"folderMethod": "2. اختر طريقة تحديد المجلد",
|
||||
"folderMethod": "3. اختر طريقة تحديد المجلد",
|
||||
"folderMethodDesc": "كيف سيتم تحديد مجلد البريد المستهدف؟",
|
||||
"folderStructure": "2. هيكل المجلدات",
|
||||
"folderStructure": "3. هيكل المجلدات",
|
||||
"importHistory": "سجل الاستيراد",
|
||||
"limits": "الحد الأقصى: EML 100 م.ب · MBOX {{maxMbox}} م.ب · PST {{maxPst}} م.ب. للملفات الأكبر ← CLI.",
|
||||
"modeCustom": "أدخل اسم مجلد مخصص",
|
||||
@@ -680,6 +759,7 @@
|
||||
"modeHeaderDesc": "قراءة X-Gmail-Labels / X-Bichon-Metadata من الملف. يعتمد على اسم الملف كبديل.",
|
||||
"noAccountFound": "لم يتم العثور على حساب.",
|
||||
"noFileYet": "لم يتم اختيار أي ملف بعد",
|
||||
"noFilesSelected": "لم يتم تحديد أي ملفات",
|
||||
"noMailboxFound": "لم يتم العثور على صندوق بريد.",
|
||||
"noMailboxes": "لم يتم العثور على صناديق بريد في هذا الحساب.",
|
||||
"orClick": "أو انقر للتصفح",
|
||||
@@ -690,8 +770,11 @@
|
||||
"searchAccount": "البحث عن الحسابات...",
|
||||
"searchMailbox": "البحث عن صناديق البريد...",
|
||||
"selectAccount": "اختر حسابًا",
|
||||
"selectAccountAndFiles": "يرجى تحديد الحساب المستهدف والملفات أولاً.",
|
||||
"selectAccountFirst": "يرجى اختيار حساب أولاً.",
|
||||
"selectAccountRequired": "يرجى تحديد الحساب المستهدف أولاً.",
|
||||
"selectFileFirst": "يرجى اختيار ملف أولاً لتحديد الخيارات المتاحة.",
|
||||
"selectFilesRequired": "يرجى تحديد الملفات للاستيراد.",
|
||||
"selectMailbox": "اختر صندوق بريد...",
|
||||
"source": "المصدر",
|
||||
"startImport": "استيراد",
|
||||
@@ -702,6 +785,46 @@
|
||||
"uploadingFile": "جاري رفع الملف",
|
||||
"willImportTo": "سيتم الاستيراد إلى"
|
||||
},
|
||||
"license": {
|
||||
"accounts": "الحسابات",
|
||||
"accountsUsed": "مُستخدم {{used}} من {{limit}}",
|
||||
"chooseFile": "اختر ملف",
|
||||
"copied": "تم النسخ إلى الحافظة",
|
||||
"copyFailed": "فشل النسخ",
|
||||
"copyMachineId": "نسخ معرف الجهاز",
|
||||
"description": "عرض تفاصيل الترخيص الحالي الخاص بك وتحديث الاعتمادات.",
|
||||
"edition": "الإصدار",
|
||||
"features": "الميزات",
|
||||
"forbidden": "إدارة التراخيص متاحة فقط في الإصدار الاحترافي (Pro).",
|
||||
"licensee": "المرخَّص له",
|
||||
"loadFailed": "فشل في تحميل حالة الترخيص.",
|
||||
"machineIdDesc": "معرف فريد لهذا الجهاز مطلوب لإنشاء ترخيص دون اتصال.",
|
||||
"machineIdTitle": "معرف الجهاز",
|
||||
"notAvailable": "غير متوفر",
|
||||
"pasteHere": "الصق محتوى الترخيص هنا...",
|
||||
"readFileFailed": "فشل في قراءة الملف",
|
||||
"status": "الحالة",
|
||||
"statusDesc": "تفاصيل التنشيط والميزات الحالية الخاصة بك",
|
||||
"statusError": "خطأ في الترخيص",
|
||||
"statusInvalid": "توقيع غير صالحة",
|
||||
"statusMachineMismatch": "معرف الجهاز غير متطابق",
|
||||
"statusTitle": "حالة الترخيص",
|
||||
"statusTrial": "تجريبي",
|
||||
"statusTrialExpired": "انتهت الفترة التجريبية",
|
||||
"statusUpdateExpired": "انتهت فترة التحديثات",
|
||||
"statusValid": "صالحة",
|
||||
"title": "إدارة التراخيص",
|
||||
"trialDays": "أيام التجربة",
|
||||
"trialDaysRemaining": "متبقي {{days}} يوم",
|
||||
"updatesUntil": "التحديثات حتى",
|
||||
"upload": "تحميل",
|
||||
"uploadDesc": "قم بتحميل ملف الترخيص الخاص بك أو لصق المحتوى مباشرة لتطبيق التحديثات.",
|
||||
"uploadFailed": "فشل تحميل الترخيص",
|
||||
"uploadFailedDesc": "تعذر التحقق من صحة ملف الترخيص أو التحقق منه.",
|
||||
"uploadSuccess": "تم تحميل الترخيص بنجاح",
|
||||
"uploadTitle": "تحديث الترخيص",
|
||||
"uploading": "جاري التحميل..."
|
||||
},
|
||||
"mail": {
|
||||
"account": "الحساب",
|
||||
"attachments": "المرفقات",
|
||||
@@ -791,10 +914,12 @@
|
||||
"accounts": "الحسابات",
|
||||
"apiDocs": "توثيق واجهة برمجة التطبيقات",
|
||||
"attachment": "المرفقات",
|
||||
"auditLog": "سجل التدقيق",
|
||||
"auth": "المصادقة",
|
||||
"dashboard": "لوحة التحكم",
|
||||
"general": "عام",
|
||||
"home": "الرئيسية",
|
||||
"license": "الترخيص",
|
||||
"mailbox": "صندوق البريد",
|
||||
"oauth2": "OAuth2",
|
||||
"other": "أخرى",
|
||||
@@ -1422,6 +1547,10 @@
|
||||
}
|
||||
},
|
||||
"proxy": "وكيل",
|
||||
"proxyTest": "اختبار الوكيل",
|
||||
"proxyTestFailed": "فشل اتصال الوكيل.",
|
||||
"proxyTestSuccess": "تم اتصال الوكيل بنجاح!",
|
||||
"proxyTesting": "جاري اختبار الوكيل...",
|
||||
"proxyUpdateOrAddFailed": "فشل {{action}}، يرجى المحاولة مرة أخرى لاحقًا",
|
||||
"reset": "إعادة تعيين",
|
||||
"resetRootPassword": "إعادة تعيين كلمة مرور الجذر",
|
||||
|
||||
@@ -98,6 +98,7 @@
|
||||
"allMailSkipped": "Valgte standardmapper. \"Al mail\" blev sprunget over for at undgå dubletter.",
|
||||
"areYouSureYouWantTo": "Er du sikker på, at du vil {{action}} denne konto?",
|
||||
"auth": "Godkendelse",
|
||||
"authPassword": "Godkendelseskodeord",
|
||||
"authType": "godkendelsestype",
|
||||
"autoConfiguring": "Konfigurerer automatisk…",
|
||||
"autoDiscover": "Find serverindstillinger automatisk",
|
||||
@@ -116,6 +117,7 @@
|
||||
"clickSaveWhenDone": "Klik på Gem, når du er færdig.",
|
||||
"continue": "Fortsæt",
|
||||
"createdAt": "Oprettet",
|
||||
"creating": "Opretter konto...",
|
||||
"creationFailed": "Oprettelse mislykkedes, prøv venligst igen senere",
|
||||
"cronAdvanced": "Avanceret udtryk",
|
||||
"cronDaily": "Dagligt",
|
||||
@@ -333,18 +335,26 @@
|
||||
"selectedMailboxes": "Valgte postkasser",
|
||||
"serverConfiguration": "Serverkonfiguration (IMAP)",
|
||||
"settings": {
|
||||
"backToAccounts": "Tilbage til konti",
|
||||
"download": "Download",
|
||||
"downloadDesc": "Konfigurer, hvornår og hvordan e-mails hentes fra serveren.",
|
||||
"filters": "Filtre",
|
||||
"filtersDesc": "Styr, hvilke e-mails der arkiveres. Når filtrering er deaktiveret, gemmes alle e-mails.",
|
||||
"general": "Generelt",
|
||||
"generalDesc": "Generelle kontooplysninger og status.",
|
||||
"loading": "Indlæser indstillinger...",
|
||||
"newAccount": "Ny konto",
|
||||
"performance": "Ydeevne",
|
||||
"reset": "Nulstil indstillinger",
|
||||
"save": "Gem indstillinger",
|
||||
"saved": "Gemt",
|
||||
"savedDesc": "Kontoindstillinger er gemt.",
|
||||
"saving": "Gemmer indstillinger...",
|
||||
"schedule": "Tidsplan",
|
||||
"scope": "Omfang",
|
||||
"server": "Server",
|
||||
"serverDesc": "IMAP-forbindelsesindstillinger og godkendelse."
|
||||
"serverDesc": "IMAP-forbindelsesindstillinger og godkendelse.",
|
||||
"settings": "Kontoindstillinger"
|
||||
},
|
||||
"since": "siden",
|
||||
"sinceFixed": "Siden specifik dato",
|
||||
@@ -470,6 +480,7 @@
|
||||
"downloading": "Downloader...",
|
||||
"emailMessageNotFound": "Kan ikke finde den originale e-mail. Den er muligvis blevet slettet.",
|
||||
"name": "Filnavn",
|
||||
"preview": "Forhåndsvis vedhæftet fil",
|
||||
"search_input_placeholder": "Søg efter vedhæftede filer (brug \" \" til frasesøgning)",
|
||||
"sender": "Afsender",
|
||||
"sender_with_count": "Afsender ({{count}})",
|
||||
@@ -487,6 +498,70 @@
|
||||
"zoomIn": "Zoom ind",
|
||||
"zoomOut": "Zoom ud"
|
||||
},
|
||||
"audit": {
|
||||
"account": "Konto",
|
||||
"accountPlaceholder": "Vælg konto",
|
||||
"allAccounts": "Alle konti",
|
||||
"allTypes": "Alle typer",
|
||||
"allUsers": "Alle brugere",
|
||||
"apply": "Anvend",
|
||||
"detail": "Detalje",
|
||||
"empty": "Ingen aktivitets-hændelser fundet",
|
||||
"endDate": "Slutdato",
|
||||
"eventType": "Hændelsestype",
|
||||
"eventTypes": {
|
||||
"accessTokenCreated": "Adgangstoken oprettet",
|
||||
"accessTokenRemoved": "Adgangstoken fjernet",
|
||||
"accountCreated": "Konto oprettet",
|
||||
"accountDownloadStarted": "Kontosynkronisering startet",
|
||||
"accountDownloadStopped": "Kontosynkronisering stoppet",
|
||||
"accountRemoved": "Konto fjernet",
|
||||
"accountRoleAssigned": "Kontoadgang tildelt",
|
||||
"accountUpdated": "Konto opdateret",
|
||||
"attachmentDownloaded": "Vedhæftning downloadet",
|
||||
"attachmentPreviewed": "Vedhæftning forhåndsvist",
|
||||
"attachmentTagged": "Vedhæftningstags ændret",
|
||||
"emailDeleted": "E-mail slettet",
|
||||
"emailExported": "E-mail eksporteret",
|
||||
"emailRestored": "E-mail genoprettet",
|
||||
"emailTagged": "E-mail-tags ændret",
|
||||
"emailViewed": "E-mail vist",
|
||||
"importPerformed": "Import udført",
|
||||
"licenseUploaded": "Licens uploadet",
|
||||
"mailboxRemoved": "Postkasse fjernet",
|
||||
"oauth2Created": "OAuth2-konfiguration oprettet",
|
||||
"oauth2Removed": "OAuth2-konfiguration fjernet",
|
||||
"oauth2TokenStored": "OAuth2-token gemt",
|
||||
"oauth2Updated": "OAuth2-konfiguration opdateret",
|
||||
"proxyCreated": "Proxy oprettet",
|
||||
"proxyRemoved": "Proxy fjernet",
|
||||
"proxyUpdated": "Proxy opdateret",
|
||||
"roleCreated": "Rolle oprettet",
|
||||
"roleRemoved": "Rolle fjernet",
|
||||
"roleUpdated": "Rolle opdateret",
|
||||
"searchPerformed": "Søgning udført",
|
||||
"settingsChanged": "Indstillinger ændret",
|
||||
"ssoLogin": "SSO-login",
|
||||
"ssoLogout": "SSO-logud",
|
||||
"userCreated": "Bruger oprettet",
|
||||
"userLogin": "Brugerlogin",
|
||||
"userRemoved": "Bruger fjernet",
|
||||
"userUpdated": "Bruger opdateret"
|
||||
},
|
||||
"forbidden": "Aktivitetsloggen er kun tilgængelig i Pro-udgaven.",
|
||||
"hideDetails": "Skjul detaljer",
|
||||
"ip": "IP",
|
||||
"loading": "Indlæser...",
|
||||
"noAccounts": "Ingen konti fundet",
|
||||
"noUsers": "Ingen brugere fundet",
|
||||
"reset": "Nulstil",
|
||||
"showDetails": "Vis detaljer",
|
||||
"startDate": "Startdato",
|
||||
"time": "Tidspunkt",
|
||||
"title": "Aktivitetslog",
|
||||
"user": "Bruger",
|
||||
"userPlaceholder": "brugernavn"
|
||||
},
|
||||
"auth": {
|
||||
"areYouSureYouWantToLogOut": "Er du sikker på, du vil logge ud?",
|
||||
"invalidPassword": "Ugyldig adgangskode. Prøv venligst igen.",
|
||||
@@ -497,6 +572,7 @@
|
||||
"sessionExpired": "Session udløbet!",
|
||||
"sessionExpiredDesc": "Din session er afsluttet på grund af inaktivitet. Log venligst ind igen for at fortsætte.",
|
||||
"somethingWentWrong": "Noget gik galt",
|
||||
"ssoLogin": "SSO-login",
|
||||
"username": "Brugernavn",
|
||||
"welcome": "Velkommen til Bichon",
|
||||
"youWillNeedToLogInAgain": "Du skal logge ind igen for at få adgang til din konto."
|
||||
@@ -657,19 +733,22 @@
|
||||
},
|
||||
"import": {
|
||||
"account": "Konto",
|
||||
"chooseFiles": "3. Vælg filer",
|
||||
"chooseFiles": "2. Vælg filer",
|
||||
"completed": "Import fuldført",
|
||||
"description": "Importer e-mailfiler til en lokal konto (NoSync). Brug CLI til større filer.",
|
||||
"detectedFolder": "Registreret",
|
||||
"detectedFrom": "Registreret fra",
|
||||
"dropHere": "Slip .eml / .mbox / .pst-filer her",
|
||||
"duplicateCount": "{{count}} dubletter sprunget over",
|
||||
"duplicateCountHint": "Disse beskeder er allerede arkiveret",
|
||||
"failed": "Import mislykkedes",
|
||||
"failedCount": "{{count}} fejlet",
|
||||
"failedDetails": "Fejlede elementer",
|
||||
"fileCount": "{{count}} filer",
|
||||
"folder": "Mappe",
|
||||
"folderMethod": "2. Vælg mappemetode",
|
||||
"folderMethod": "3. Vælg mappemetode",
|
||||
"folderMethodDesc": "Hvordan skal destinationsmappen bestemmes?",
|
||||
"folderStructure": "2. Mappestruktur",
|
||||
"folderStructure": "3. Mappestruktur",
|
||||
"importHistory": "Importhistorik",
|
||||
"limits": "Maks: EML 100 MB · MBOX {{maxMbox}} MB · PST {{maxPst}} MB. Større filer → CLI.",
|
||||
"modeCustom": "Indtast et brugerdefineret mappenavn",
|
||||
@@ -680,6 +759,7 @@
|
||||
"modeHeaderDesc": "Læs X-Gmail-Labels / X-Bichon-Metadata fra filen. Falder tilbage til filnavn.",
|
||||
"noAccountFound": "Ingen konto fundet.",
|
||||
"noFileYet": "Ingen fil valgt endnu",
|
||||
"noFilesSelected": "Ingen filer valgt",
|
||||
"noMailboxFound": "Ingen postkasse fundet.",
|
||||
"noMailboxes": "Ingen postkasser fundet på denne konto.",
|
||||
"orClick": "eller klik for at gennemse",
|
||||
@@ -690,8 +770,11 @@
|
||||
"searchAccount": "Søg efter konti...",
|
||||
"searchMailbox": "Søg efter postkasser...",
|
||||
"selectAccount": "Vælg en konto",
|
||||
"selectAccountAndFiles": "Vælg venligst en målkonto og filer først.",
|
||||
"selectAccountFirst": "Vælg en konto først.",
|
||||
"selectAccountRequired": "Vælg venligst en målkonto først.",
|
||||
"selectFileFirst": "Vælg en fil først for at bestemme tilgængelige muligheder.",
|
||||
"selectFilesRequired": "Vælg venligst filer, der skal importeres.",
|
||||
"selectMailbox": "Vælg en postkasse...",
|
||||
"source": "kilde",
|
||||
"startImport": "Importer",
|
||||
@@ -702,6 +785,46 @@
|
||||
"uploadingFile": "Uploader fil",
|
||||
"willImportTo": "Vil blive importeret til"
|
||||
},
|
||||
"license": {
|
||||
"accounts": "Konti",
|
||||
"accountsUsed": "{{used}} af {{limit}} bruges",
|
||||
"chooseFile": "Vælg fil",
|
||||
"copied": "Kopieret til udklipsholder",
|
||||
"copyFailed": "Kunne ikke kopiere",
|
||||
"copyMachineId": "Kopier maskin-ID",
|
||||
"description": "Se dine aktuelle licensdetaljer og opdater legitimationsoplysninger.",
|
||||
"edition": "Udgave",
|
||||
"features": "Funktioner",
|
||||
"forbidden": "Licensstyring er kun tilgængelig i Pro-udgaven.",
|
||||
"licensee": "Licensindehaver",
|
||||
"loadFailed": "Kunne ikke indlæse licensstatus.",
|
||||
"machineIdDesc": "Unik identifikator for denne enhed, der kræves for at generere en offline licens.",
|
||||
"machineIdTitle": "Maskin-ID",
|
||||
"notAvailable": "Ikke tilgængelig",
|
||||
"pasteHere": "Indsæt licensindhold her...",
|
||||
"readFileFailed": "Kunne ikke læse filen",
|
||||
"status": "Status",
|
||||
"statusDesc": "Dine aktuelle aktiverings- og funktionsdetaljer",
|
||||
"statusError": "Licensfejl",
|
||||
"statusInvalid": "Ugyldig signatur",
|
||||
"statusMachineMismatch": "Maskin-ID matcher ikke",
|
||||
"statusTitle": "Licensstatus",
|
||||
"statusTrial": "Prøveperiod",
|
||||
"statusTrialExpired": "Prøveperiode udløbet",
|
||||
"statusUpdateExpired": "Opdateringsperiode udløbet",
|
||||
"statusValid": "Gyldig",
|
||||
"title": "Licensstyring",
|
||||
"trialDays": "Prøvedage",
|
||||
"trialDaysRemaining": "{{days}} dage tilbage",
|
||||
"updatesUntil": "Opdateringer indtil",
|
||||
"upload": "Upload",
|
||||
"uploadDesc": "Upload din licensfil eller indsæt indholdet direkte for at anvende opdateringer.",
|
||||
"uploadFailed": "Kunne ikke uploade licens",
|
||||
"uploadFailedDesc": "Kunne ikke parse eller validere licensfilen.",
|
||||
"uploadSuccess": "Licens blev uploadet",
|
||||
"uploadTitle": "Opdater licens",
|
||||
"uploading": "Uploader..."
|
||||
},
|
||||
"mail": {
|
||||
"account": "Konto",
|
||||
"attachments": "Vedhæftninger",
|
||||
@@ -791,10 +914,12 @@
|
||||
"accounts": "Konti",
|
||||
"apiDocs": "API-dokumentation",
|
||||
"attachment": "Vedhæftede filer",
|
||||
"auditLog": "Revisjonslogg",
|
||||
"auth": "Godkendelse",
|
||||
"dashboard": "Oversigt",
|
||||
"general": "Generelt",
|
||||
"home": "Hjem",
|
||||
"license": "Licens",
|
||||
"mailbox": "Mailboks",
|
||||
"oauth2": "OAuth2",
|
||||
"other": "Andet",
|
||||
@@ -1422,6 +1547,10 @@
|
||||
}
|
||||
},
|
||||
"proxy": "Proxy",
|
||||
"proxyTest": "Test proxy",
|
||||
"proxyTestFailed": "Proxyforbindelse mislykkedes.",
|
||||
"proxyTestSuccess": "Proxyforbindelse lykkedes!",
|
||||
"proxyTesting": "Tester proxy...",
|
||||
"proxyUpdateOrAddFailed": "{{action}} mislykkedes, prøv venligst igen senere",
|
||||
"reset": "Nulstil",
|
||||
"resetRootPassword": "Nulstil Root-adgangskode",
|
||||
|
||||
@@ -98,6 +98,7 @@
|
||||
"allMailSkipped": "Standardordner ausgewählt. 'Alle E-Mails' wurde übersprungen, um Duplikate zu vermeiden.",
|
||||
"areYouSureYouWantTo": "Möchten Sie dieses Konto wirklich {{action}}?",
|
||||
"auth": "Authentifizierung",
|
||||
"authPassword": "Authentifizierungspasswort",
|
||||
"authType": "Authentifizierungstyp",
|
||||
"autoConfiguring": "Automatische Konfiguration…",
|
||||
"autoDiscover": "Servereinstellungen automatisch erkennen",
|
||||
@@ -116,6 +117,7 @@
|
||||
"clickSaveWhenDone": "Klicken Sie auf Speichern, wenn Sie fertig sind.",
|
||||
"continue": "Weiter",
|
||||
"createdAt": "Erstellt am",
|
||||
"creating": "Konto wird erstellt...",
|
||||
"creationFailed": "Erstellung fehlgeschlagen, bitte versuchen Sie es später erneut",
|
||||
"cronAdvanced": "Erweiterter Ausdruck",
|
||||
"cronDaily": "Täglich",
|
||||
@@ -333,18 +335,26 @@
|
||||
"selectedMailboxes": "Ausgewählte Postfächer",
|
||||
"serverConfiguration": "Serverkonfiguration (IMAP)",
|
||||
"settings": {
|
||||
"backToAccounts": "Zurück zu den Konten",
|
||||
"download": "Herunterladen",
|
||||
"downloadDesc": "Konfigurieren, wann und wie E-Mails vom Server abgerufen werden.",
|
||||
"filters": "Filter",
|
||||
"filtersDesc": "Steuern Sie, welche E-Mails archiviert werden. Wenn die Filterung deaktiviert ist, werden alle E-Mails gespeichert.",
|
||||
"general": "Allgemein",
|
||||
"generalDesc": "Basis-Kontoinformationen und Status.",
|
||||
"loading": "Einstellungen werden geladen...",
|
||||
"newAccount": "Neues Konto",
|
||||
"performance": "Leistung",
|
||||
"reset": "Einstellungen zurücksetzen",
|
||||
"save": "Einstellungen speichern",
|
||||
"saved": "Gespeichert",
|
||||
"savedDesc": "Kontoeinstellungen wurden erfolgreich gespeichert.",
|
||||
"saving": "Einstellungen werden gespeichert...",
|
||||
"schedule": "Zeitplan",
|
||||
"scope": "Zeitraum",
|
||||
"server": "Server",
|
||||
"serverDesc": "IMAP-Verbindungseinstellungen und Authentifizierung."
|
||||
"serverDesc": "IMAP-Verbindungseinstellungen und Authentifizierung.",
|
||||
"settings": "Kontoeinstellungen"
|
||||
},
|
||||
"since": "seit",
|
||||
"sinceFixed": "Seit einem bestimmten Datum",
|
||||
@@ -470,6 +480,7 @@
|
||||
"downloading": "Herunterladen...",
|
||||
"emailMessageNotFound": "Die ursprüngliche E-Mail wurde nicht gefunden. Sie wurde möglicherweise gelöscht.",
|
||||
"name": "Dateiname",
|
||||
"preview": "Anhangsvorschau",
|
||||
"search_input_placeholder": "Anhänge durchsuchen (verwenden Sie \" \" für die Phrasensuche)",
|
||||
"sender": "Absender",
|
||||
"sender_with_count": "Absender ({{count}})",
|
||||
@@ -487,6 +498,70 @@
|
||||
"zoomIn": "Vergrößern",
|
||||
"zoomOut": "Verkleinern"
|
||||
},
|
||||
"audit": {
|
||||
"account": "Konto",
|
||||
"accountPlaceholder": "Konto auswählen",
|
||||
"allAccounts": "Alle Konten",
|
||||
"allTypes": "Alle Typen",
|
||||
"allUsers": "Alle Benutzer",
|
||||
"apply": "Anwenden",
|
||||
"detail": "Detail",
|
||||
"empty": "Keine Audit-Ereignisse gefunden",
|
||||
"endDate": "Enddatum",
|
||||
"eventType": "Ereignistyp",
|
||||
"eventTypes": {
|
||||
"accessTokenCreated": "Zugriffstoken erstellt",
|
||||
"accessTokenRemoved": "Zugriffstoken entfernt",
|
||||
"accountCreated": "Konto erstellt",
|
||||
"accountDownloadStarted": "Konto-Synch. gestartet",
|
||||
"accountDownloadStopped": "Konto-Synch. gestoppt",
|
||||
"accountRemoved": "Konto entfernt",
|
||||
"accountRoleAssigned": "Konto-Zugriff zugewiesen",
|
||||
"accountUpdated": "Konto aktualisiert",
|
||||
"attachmentDownloaded": "Anhang heruntergeladen",
|
||||
"attachmentPreviewed": "Anhang angezeigt",
|
||||
"attachmentTagged": "Anhang-Tags geändert",
|
||||
"emailDeleted": "E-Mail gelöscht",
|
||||
"emailExported": "E-Mail exportiert",
|
||||
"emailRestored": "E-Mail wiederhergestellt",
|
||||
"emailTagged": "E-Mail-Tags geändert",
|
||||
"emailViewed": "E-Mail angezeigt",
|
||||
"importPerformed": "Import ausgeführt",
|
||||
"licenseUploaded": "Lizenz hochgeladen",
|
||||
"mailboxRemoved": "Postfach entfernt",
|
||||
"oauth2Created": "OAuth2-Konfiguration erstellt",
|
||||
"oauth2Removed": "OAuth2-Konfiguration entfernt",
|
||||
"oauth2TokenStored": "OAuth2-Token gespeichert",
|
||||
"oauth2Updated": "OAuth2-Konfiguration aktualisiert",
|
||||
"proxyCreated": "Proxy erstellt",
|
||||
"proxyRemoved": "Proxy entfernt",
|
||||
"proxyUpdated": "Proxy aktualisiert",
|
||||
"roleCreated": "Rolle erstellt",
|
||||
"roleRemoved": "Rolle entfernt",
|
||||
"roleUpdated": "Rolle aktualisiert",
|
||||
"searchPerformed": "Suche ausgeführt",
|
||||
"settingsChanged": "Einstellungen geändert",
|
||||
"ssoLogin": "SSO-Anmeldung",
|
||||
"ssoLogout": "SSO-Abmeldung",
|
||||
"userCreated": "Benutzer erstellt",
|
||||
"userLogin": "Benutzeranmeldung",
|
||||
"userRemoved": "Benutzer entfernt",
|
||||
"userUpdated": "Benutzer aktualisiert"
|
||||
},
|
||||
"forbidden": "Das Audit-Log ist nur in der Pro-Edition verfügbar.",
|
||||
"hideDetails": "Details ausblenden",
|
||||
"ip": "IP",
|
||||
"loading": "Wird geladen...",
|
||||
"noAccounts": "Keine Konten gefunden",
|
||||
"noUsers": "Keine Benutzer gefunden",
|
||||
"reset": "Zurücksetzen",
|
||||
"showDetails": "Details anzeigen",
|
||||
"startDate": "Startdatum",
|
||||
"time": "Zeit",
|
||||
"title": "Audit-Log",
|
||||
"user": "Benutzer",
|
||||
"userPlaceholder": "Benutzername"
|
||||
},
|
||||
"auth": {
|
||||
"areYouSureYouWantToLogOut": "Sind Sie sicher, dass Sie sich abmelden möchten?",
|
||||
"invalidPassword": "Ungültiges Passwort. Bitte versuchen Sie es erneut.",
|
||||
@@ -497,6 +572,7 @@
|
||||
"sessionExpired": "Sitzung abgelaufen!",
|
||||
"sessionExpiredDesc": "Ihre Sitzung ist aufgrund von Inaktivität abgelaufen. Bitte melden Sie sich an, um fortzufahren.",
|
||||
"somethingWentWrong": "Etwas ist schiefgelaufen",
|
||||
"ssoLogin": "SSO-Anmeldung",
|
||||
"username": "Benutzername",
|
||||
"welcome": "Willkommen bei Bichon",
|
||||
"youWillNeedToLogInAgain": "Sie müssen sich erneut anmelden, um auf Ihr Konto zuzugreifen."
|
||||
@@ -657,19 +733,22 @@
|
||||
},
|
||||
"import": {
|
||||
"account": "Konto",
|
||||
"chooseFiles": "3. Dateien auswählen",
|
||||
"chooseFiles": "2. Dateien auswählen",
|
||||
"completed": "Import abgeschlossen",
|
||||
"description": "E-Mail-Dateien in ein lokales Konto (NoSync) importieren. Für größere Dateien CLI nutzen.",
|
||||
"detectedFolder": "Erkannt",
|
||||
"detectedFrom": "Erkannt aus",
|
||||
"dropHere": ".eml / .mbox / .pst-Dateien hierher ziehen",
|
||||
"duplicateCount": "{{count}} Duplikate übersprungen",
|
||||
"duplicateCountHint": "Diese Nachrichten sind bereits archiviert",
|
||||
"failed": "Import fehlgeschlagen",
|
||||
"failedCount": "{{count}} fehlgeschlagen",
|
||||
"failedDetails": "Fehlgeschlagene Elemente",
|
||||
"fileCount": "{{count}} Dateien",
|
||||
"folder": "Ordner",
|
||||
"folderMethod": "2. Ordnermethode wählen",
|
||||
"folderMethod": "3. Ordnermethode wählen",
|
||||
"folderMethodDesc": "Wie soll der Zielordner bestimmt werden?",
|
||||
"folderStructure": "2. Ordnerstruktur",
|
||||
"folderStructure": "3. Ordnerstruktur",
|
||||
"importHistory": "Importverlauf",
|
||||
"limits": "Max: EML 100 MB · MBOX {{maxMbox}} MB · PST {{maxPst}} MB. Größere Dateien → CLI.",
|
||||
"modeCustom": "Benutzerdefinierten Ordnernamen eingeben",
|
||||
@@ -680,6 +759,7 @@
|
||||
"modeHeaderDesc": "Liest X-Gmail-Labels / X-Bichon-Metadata aus der Datei. Fallback auf Dateiname.",
|
||||
"noAccountFound": "Kein Konto gefunden.",
|
||||
"noFileYet": "Noch keine Datei ausgewählt",
|
||||
"noFilesSelected": "Keine Dateien ausgewählt",
|
||||
"noMailboxFound": "Kein Postfach gefunden.",
|
||||
"noMailboxes": "Keine Postfächer in diesem Konto gefunden.",
|
||||
"orClick": "oder zum Durchsuchen klicken",
|
||||
@@ -690,8 +770,11 @@
|
||||
"searchAccount": "Konten suchen...",
|
||||
"searchMailbox": "Postfächer suchen...",
|
||||
"selectAccount": "Konto auswählen",
|
||||
"selectAccountAndFiles": "Bitte wählen Sie zuerst ein Zielkonto und Dateien aus.",
|
||||
"selectAccountFirst": "Wählen Sie zuerst ein Konto aus.",
|
||||
"selectAccountRequired": "Bitte wählen Sie zuerst ein Zielkonto aus.",
|
||||
"selectFileFirst": "Wählen Sie zuerst eine Datei aus, um die verfügbaren Optionen zu ermitteln.",
|
||||
"selectFilesRequired": "Bitte wählen Sie die zu importierenden Dateien aus.",
|
||||
"selectMailbox": "Postfach auswählen...",
|
||||
"source": "Quelle",
|
||||
"startImport": "Importieren",
|
||||
@@ -702,6 +785,46 @@
|
||||
"uploadingFile": "Datei wird hochgeladen",
|
||||
"willImportTo": "Wird importiert in"
|
||||
},
|
||||
"license": {
|
||||
"accounts": "Konten",
|
||||
"accountsUsed": "{{used}} von {{limit}} verwendet",
|
||||
"chooseFile": "Datei auswählen",
|
||||
"copied": "In die Zwischenablage kopiert",
|
||||
"copyFailed": "Kopieren fehlgeschlagen",
|
||||
"copyMachineId": "Maschinen-ID kopieren",
|
||||
"description": "Zeigen Sie Ihre aktuellen Lizenzdetails an und aktualisieren Sie die Anmeldeinformationen.",
|
||||
"edition": "Edition",
|
||||
"features": "Funktionen",
|
||||
"forbidden": "Die Lizenzverwaltung ist nur in der Pro-Edition verfügbar.",
|
||||
"licensee": "Lizenznehmer",
|
||||
"loadFailed": "Lizenzstatus konnte nicht geladen werden.",
|
||||
"machineIdDesc": "Eindeutige Kennung für dieses Gerät, die zum Generieren einer Offline-Lizenz erforderlich ist.",
|
||||
"machineIdTitle": "Maschinen-ID",
|
||||
"notAvailable": "Nicht verfügbar",
|
||||
"pasteHere": "Lizenzinhalt hier einfügen...",
|
||||
"readFileFailed": "Datei konnte nicht gelesen werden",
|
||||
"status": "Status",
|
||||
"statusDesc": "Ihre aktuellen Aktivierungs- und Funktionsdetails",
|
||||
"statusError": "Lizenzfehler",
|
||||
"statusInvalid": "Ungültige Signatur",
|
||||
"statusMachineMismatch": "Maschinen-ID stimmt nicht überein",
|
||||
"statusTitle": "Lizenzstatus",
|
||||
"statusTrial": "Testversion",
|
||||
"statusTrialExpired": "Testversion abgelaufen",
|
||||
"statusUpdateExpired": "Updates abgelaufen",
|
||||
"statusValid": "Gültig",
|
||||
"title": "Lizenzverwaltung",
|
||||
"trialDays": "Testtage",
|
||||
"trialDaysRemaining": "Noch {{days}} Tage",
|
||||
"updatesUntil": "Updates bis",
|
||||
"upload": "Hochladen",
|
||||
"uploadDesc": "Laden Sie Ihre Lizenzdatei hoch oder fügen Sie den Inhalt direkt ein, um Aktualisierungen anzuwenden.",
|
||||
"uploadFailed": "Upload der Lizenz fehlgeschlagen",
|
||||
"uploadFailedDesc": "Lizenzdatei konnte nicht analysiert oder überprüft werden.",
|
||||
"uploadSuccess": "Lizenz erfolgreich hochgeladen",
|
||||
"uploadTitle": "Lizenz aktualisieren",
|
||||
"uploading": "Wird hochgeladen..."
|
||||
},
|
||||
"mail": {
|
||||
"account": "Konto",
|
||||
"attachments": "Anhänge",
|
||||
@@ -791,10 +914,12 @@
|
||||
"accounts": "Konten",
|
||||
"apiDocs": "API-Dokumentation",
|
||||
"attachment": "Anhänge",
|
||||
"auditLog": "Audit-Protokoll",
|
||||
"auth": "Authentifizierung",
|
||||
"dashboard": "Dashboard",
|
||||
"general": "Allgemein",
|
||||
"home": "Startseite",
|
||||
"license": "Lizenz",
|
||||
"mailbox": "Postfach",
|
||||
"oauth2": "OAuth2",
|
||||
"other": "Sonstiges",
|
||||
@@ -1422,6 +1547,10 @@
|
||||
}
|
||||
},
|
||||
"proxy": "Proxy",
|
||||
"proxyTest": "Proxy testen",
|
||||
"proxyTestFailed": "Proxy-Verbindung fehlgeschlagen.",
|
||||
"proxyTestSuccess": "Proxy-Verbindung erfolgreich!",
|
||||
"proxyTesting": "Proxy wird getestet...",
|
||||
"proxyUpdateOrAddFailed": "Proxy {{action}} fehlgeschlagen, bitte versuchen Sie es später erneut",
|
||||
"reset": "Zurücksetzen",
|
||||
"resetRootPassword": "Root-Passwort zurücksetzen",
|
||||
|
||||
@@ -98,7 +98,7 @@
|
||||
"allMailSkipped": "Selected standard folders. 'All Mail' was skipped to avoid duplicates.",
|
||||
"areYouSureYouWantTo": "Are you sure you want to {{action}} this account?",
|
||||
"auth": "Auth",
|
||||
"authPassword": "Password",
|
||||
"authPassword": "Authentication password",
|
||||
"authType": "auth_type",
|
||||
"autoConfiguring": "Auto-configuring...",
|
||||
"autoDiscover": "Auto-discover Server Settings",
|
||||
@@ -117,7 +117,7 @@
|
||||
"clickSaveWhenDone": "Click save when you're done.",
|
||||
"continue": "Continue",
|
||||
"createdAt": "Created At",
|
||||
"creating": "Creating...",
|
||||
"creating": "Creating account...",
|
||||
"creationFailed": "Creation failed, please try again later",
|
||||
"cronAdvanced": "Advanced Expression",
|
||||
"cronDaily": "Daily",
|
||||
@@ -335,26 +335,26 @@
|
||||
"selectedMailboxes": "Selected Mailboxes",
|
||||
"serverConfiguration": "Server Configuration (IMAP)",
|
||||
"settings": {
|
||||
"backToAccounts": "Back to Accounts",
|
||||
"backToAccounts": "Back to accounts",
|
||||
"download": "Download",
|
||||
"downloadDesc": "Configure when and how emails are fetched from the server.",
|
||||
"filters": "Filters",
|
||||
"filtersDesc": "Control which emails are archived. When filtering is disabled, all emails are saved.",
|
||||
"general": "General",
|
||||
"generalDesc": "Basic account information and status.",
|
||||
"loading": "Loading account...",
|
||||
"loading": "Loading settings...",
|
||||
"newAccount": "New Account",
|
||||
"performance": "Performance",
|
||||
"reset": "Reset",
|
||||
"save": "Save",
|
||||
"reset": "Reset settings",
|
||||
"save": "Save settings",
|
||||
"saved": "Saved",
|
||||
"savedDesc": "Settings have been saved successfully.",
|
||||
"saving": "Saving...",
|
||||
"savedDesc": "Account settings saved successfully.",
|
||||
"saving": "Saving settings...",
|
||||
"schedule": "Schedule",
|
||||
"scope": "Scope",
|
||||
"server": "Server",
|
||||
"serverDesc": "IMAP connection settings and authentication.",
|
||||
"settings": "Settings"
|
||||
"settings": "Account settings"
|
||||
},
|
||||
"since": "since",
|
||||
"sinceFixed": "Since Specific Date",
|
||||
@@ -480,7 +480,7 @@
|
||||
"downloading": "Downloading...",
|
||||
"emailMessageNotFound": "Unable to find the original email. It may have been deleted.",
|
||||
"name": "Filename",
|
||||
"preview": "Preview",
|
||||
"preview": "Preview attachment",
|
||||
"search_input_placeholder": "Search attachments (use \" \" for phrase search)",
|
||||
"sender": "Sender",
|
||||
"sender_with_count": "Sender ({{count}})",
|
||||
@@ -498,6 +498,70 @@
|
||||
"zoomIn": "Zoom in",
|
||||
"zoomOut": "Zoom out"
|
||||
},
|
||||
"audit": {
|
||||
"account": "Account",
|
||||
"accountPlaceholder": "Select account",
|
||||
"allAccounts": "All accounts",
|
||||
"allTypes": "All",
|
||||
"allUsers": "All users",
|
||||
"apply": "Apply",
|
||||
"detail": "Detail",
|
||||
"empty": "No audit events found",
|
||||
"endDate": "End date",
|
||||
"eventType": "Event type",
|
||||
"eventTypes": {
|
||||
"accessTokenCreated": "Access token created",
|
||||
"accessTokenRemoved": "Access token removed",
|
||||
"accountCreated": "Account created",
|
||||
"accountDownloadStarted": "Account sync started",
|
||||
"accountDownloadStopped": "Account sync stopped",
|
||||
"accountRemoved": "Account removed",
|
||||
"accountRoleAssigned": "Account access assigned",
|
||||
"accountUpdated": "Account updated",
|
||||
"attachmentDownloaded": "Attachment downloaded",
|
||||
"attachmentPreviewed": "Attachment previewed",
|
||||
"attachmentTagged": "Attachment tags changed",
|
||||
"emailDeleted": "Email deleted",
|
||||
"emailExported": "Email exported",
|
||||
"emailRestored": "Email restored",
|
||||
"emailTagged": "Email tags changed",
|
||||
"emailViewed": "Email viewed",
|
||||
"importPerformed": "Import performed",
|
||||
"licenseUploaded": "License uploaded",
|
||||
"mailboxRemoved": "Mailbox removed",
|
||||
"oauth2Created": "OAuth2 config created",
|
||||
"oauth2Removed": "OAuth2 config removed",
|
||||
"oauth2TokenStored": "OAuth2 token stored",
|
||||
"oauth2Updated": "OAuth2 config updated",
|
||||
"proxyCreated": "Proxy created",
|
||||
"proxyRemoved": "Proxy removed",
|
||||
"proxyUpdated": "Proxy updated",
|
||||
"roleCreated": "Role created",
|
||||
"roleRemoved": "Role removed",
|
||||
"roleUpdated": "Role updated",
|
||||
"searchPerformed": "Search performed",
|
||||
"settingsChanged": "Settings changed",
|
||||
"ssoLogin": "SSO login",
|
||||
"ssoLogout": "SSO logout",
|
||||
"userCreated": "User created",
|
||||
"userLogin": "User login",
|
||||
"userRemoved": "User removed",
|
||||
"userUpdated": "User updated"
|
||||
},
|
||||
"forbidden": "Audit log is available in the Pro edition only.",
|
||||
"hideDetails": "Hide details",
|
||||
"ip": "IP",
|
||||
"loading": "Loading…",
|
||||
"noAccounts": "No accounts found",
|
||||
"noUsers": "No users found",
|
||||
"reset": "Reset",
|
||||
"showDetails": "Show details",
|
||||
"startDate": "Start date",
|
||||
"time": "Time",
|
||||
"title": "Audit Log",
|
||||
"user": "User",
|
||||
"userPlaceholder": "username"
|
||||
},
|
||||
"auth": {
|
||||
"areYouSureYouWantToLogOut": "Are you sure you want to log out?",
|
||||
"invalidPassword": "Invalid password. Please try again.",
|
||||
@@ -508,7 +572,7 @@
|
||||
"sessionExpired": "Session expired!",
|
||||
"sessionExpiredDesc": "Your session has ended due to inactivity. Please log in again to continue.",
|
||||
"somethingWentWrong": "Something went wrong",
|
||||
"ssoLogin": "Sign in with SSO",
|
||||
"ssoLogin": "SSO Login",
|
||||
"username": "Username",
|
||||
"welcome": "Welcome to Bichon",
|
||||
"youWillNeedToLogInAgain": "You will need to log in again to access your account."
|
||||
@@ -669,19 +733,22 @@
|
||||
},
|
||||
"import": {
|
||||
"account": "Account",
|
||||
"chooseFiles": "3. Choose files",
|
||||
"chooseFiles": "2. Choose files",
|
||||
"completed": "Import complete",
|
||||
"description": "Import email files into a local account (NoSync). For larger files, use the CLI.",
|
||||
"detectedFolder": "Detected",
|
||||
"detectedFrom": "Detected from",
|
||||
"dropHere": "Drop .eml / .mbox / .pst files here",
|
||||
"duplicateCount": "{{count}} duplicates skipped",
|
||||
"duplicateCountHint": "These messages are already in the archive",
|
||||
"failed": "Import failed",
|
||||
"failedCount": "{{count}} failed",
|
||||
"failedDetails": "Failed items",
|
||||
"fileCount": "{{count}} files",
|
||||
"folder": "Folder",
|
||||
"folderMethod": "2. Choose folder method",
|
||||
"folderMethod": "3. Choose folder method",
|
||||
"folderMethodDesc": "How should the target mail folder be determined?",
|
||||
"folderStructure": "2. Folder structure",
|
||||
"folderStructure": "3. Folder structure",
|
||||
"importHistory": "Import History",
|
||||
"limits": "Max: EML 100 MB · MBOX {{maxMbox}} MB · PST {{maxPst}} MB. Larger files → CLI.",
|
||||
"modeCustom": "Enter a custom folder name",
|
||||
@@ -692,6 +759,7 @@
|
||||
"modeHeaderDesc": "Read X-Gmail-Labels / X-Bichon-Metadata from the uploaded file. Falls back to filename.",
|
||||
"noAccountFound": "No account found.",
|
||||
"noFileYet": "No file selected yet",
|
||||
"noFilesSelected": "No files selected",
|
||||
"noMailboxFound": "No mailbox found.",
|
||||
"noMailboxes": "No mailboxes found in this account.",
|
||||
"orClick": "or click to browse",
|
||||
@@ -702,8 +770,11 @@
|
||||
"searchAccount": "Search accounts...",
|
||||
"searchMailbox": "Search mailboxes...",
|
||||
"selectAccount": "Select an account",
|
||||
"selectAccountAndFiles": "Please select a target account and files first.",
|
||||
"selectAccountFirst": "Select an account first.",
|
||||
"selectAccountRequired": "Please select a target account first.",
|
||||
"selectFileFirst": "Select a file first to determine available options.",
|
||||
"selectFilesRequired": "Please select files to import.",
|
||||
"selectMailbox": "Select a mailbox...",
|
||||
"source": "source",
|
||||
"startImport": "Import",
|
||||
@@ -714,6 +785,46 @@
|
||||
"uploadingFile": "Uploading file",
|
||||
"willImportTo": "Will import to"
|
||||
},
|
||||
"license": {
|
||||
"accounts": "Accounts",
|
||||
"accountsUsed": "{{used}} of {{limit}} used",
|
||||
"chooseFile": "Choose file",
|
||||
"copied": "Copied to clipboard",
|
||||
"copyFailed": "Failed to copy",
|
||||
"copyMachineId": "Copy machine ID",
|
||||
"description": "View your current license details and update credentials.",
|
||||
"edition": "Edition",
|
||||
"features": "Features",
|
||||
"forbidden": "License management is available in the Pro edition only.",
|
||||
"licensee": "Licensee",
|
||||
"loadFailed": "Failed to load license status.",
|
||||
"machineIdDesc": "Unique identifier for this device required to generate an offline license.",
|
||||
"machineIdTitle": "Machine ID",
|
||||
"notAvailable": "N/A",
|
||||
"pasteHere": "Paste license content here...",
|
||||
"readFileFailed": "Failed to read file",
|
||||
"status": "Status",
|
||||
"statusDesc": "Your current activation and feature details",
|
||||
"statusError": "License error",
|
||||
"statusInvalid": "Invalid signature",
|
||||
"statusMachineMismatch": "Machine ID mismatch",
|
||||
"statusTitle": "License status",
|
||||
"statusTrial": "Trial",
|
||||
"statusTrialExpired": "Trial expired",
|
||||
"statusUpdateExpired": "Updates expired",
|
||||
"statusValid": "Valid",
|
||||
"title": "License management",
|
||||
"trialDays": "Trial days",
|
||||
"trialDaysRemaining": "{{days}} days remaining",
|
||||
"updatesUntil": "Updates until",
|
||||
"upload": "Upload",
|
||||
"uploadDesc": "Upload your license file or paste the content directly to apply updates.",
|
||||
"uploadFailed": "Failed to upload license",
|
||||
"uploadFailedDesc": "Could not parse or validate the license file.",
|
||||
"uploadSuccess": "License uploaded successfully",
|
||||
"uploadTitle": "Update license",
|
||||
"uploading": "Uploading..."
|
||||
},
|
||||
"mail": {
|
||||
"account": "Account",
|
||||
"attachments": "Attachments",
|
||||
@@ -803,10 +914,12 @@
|
||||
"accounts": "Accounts",
|
||||
"apiDocs": "API Documentation",
|
||||
"attachment": "Attachments",
|
||||
"auditLog": "Audit log",
|
||||
"auth": "Auth",
|
||||
"dashboard": "Dashboard",
|
||||
"general": "General",
|
||||
"home": "Home",
|
||||
"license": "License",
|
||||
"mailbox": "Mailbox",
|
||||
"oauth2": "OAuth2",
|
||||
"other": "Other",
|
||||
@@ -1434,10 +1547,10 @@
|
||||
}
|
||||
},
|
||||
"proxy": "Proxy",
|
||||
"proxyTest": "Check Proxy",
|
||||
"proxyTestFailed": "Proxy check failed",
|
||||
"proxyTestSuccess": "Proxy works",
|
||||
"proxyTesting": "Checking...",
|
||||
"proxyTest": "Test proxy",
|
||||
"proxyTestFailed": "Proxy connection failed.",
|
||||
"proxyTestSuccess": "Proxy connection successful!",
|
||||
"proxyTesting": "Testing proxy...",
|
||||
"proxyUpdateOrAddFailed": "{{action}} failed, please try again later",
|
||||
"reset": "Reset",
|
||||
"resetRootPassword": "Reset Root Password",
|
||||
|
||||
@@ -98,6 +98,7 @@
|
||||
"allMailSkipped": "Carpetas predeterminadas seleccionadas. 'Todo el correo' omitido para evitar duplicados.",
|
||||
"areYouSureYouWantTo": "¿Está seguro de que desea {{action}} esta cuenta?",
|
||||
"auth": "Autenticación",
|
||||
"authPassword": "Contraseña de autenticación",
|
||||
"authType": "tipo de autenticación",
|
||||
"autoConfiguring": "Configurando automáticamente…",
|
||||
"autoDiscover": "Detectar automáticamente la configuración del servidor",
|
||||
@@ -116,6 +117,7 @@
|
||||
"clickSaveWhenDone": "Haz clic en Guardar cuando hayas terminado.",
|
||||
"continue": "Continuar",
|
||||
"createdAt": "Creado el",
|
||||
"creating": "Creando cuenta...",
|
||||
"creationFailed": "Error al crear, por favor, inténtalo de nuevo más tarde",
|
||||
"cronAdvanced": "Expresión avanzada",
|
||||
"cronDaily": "Diario",
|
||||
@@ -333,18 +335,26 @@
|
||||
"selectedMailboxes": "Buzones seleccionados",
|
||||
"serverConfiguration": "Configuración del servidor (IMAP)",
|
||||
"settings": {
|
||||
"backToAccounts": "Volver a las cuentas",
|
||||
"download": "Descarga",
|
||||
"downloadDesc": "Configure cuándo y cómo se obtienen los correos del servidor.",
|
||||
"filters": "Filtros",
|
||||
"filtersDesc": "Controle qué correos se archivan. Si el filtrado está desactivado, se guardarán todos.",
|
||||
"general": "General",
|
||||
"generalDesc": "Información básica de la cuenta y estado.",
|
||||
"loading": "Cargando configuración...",
|
||||
"newAccount": "Nueva cuenta",
|
||||
"performance": "Rendimiento",
|
||||
"reset": "Restablecer configuración",
|
||||
"save": "Guardar configuración",
|
||||
"saved": "Guardado",
|
||||
"savedDesc": "La configuración de la cuenta se guardó correctamente.",
|
||||
"saving": "Guardando configuración...",
|
||||
"schedule": "Planificación",
|
||||
"scope": "Alcance",
|
||||
"server": "Servidor",
|
||||
"serverDesc": "Configuración de conexión IMAP y autenticación."
|
||||
"serverDesc": "Configuración de conexión IMAP y autenticación.",
|
||||
"settings": "Configuración de la cuenta"
|
||||
},
|
||||
"since": "desde",
|
||||
"sinceFixed": "Desde una fecha específica",
|
||||
@@ -470,6 +480,7 @@
|
||||
"downloading": "Descargando...",
|
||||
"emailMessageNotFound": "No se pudo encontrar el correo electrónico original. Es posible que se haya eliminado.",
|
||||
"name": "Nombre de archivo",
|
||||
"preview": "Vista previa del archivo adjunto",
|
||||
"search_input_placeholder": "Buscar adjuntos (use \" \" para búsqueda de frases)",
|
||||
"sender": "Remitente",
|
||||
"sender_with_count": "Remitente ({{count}})",
|
||||
@@ -487,6 +498,70 @@
|
||||
"zoomIn": "Acercar",
|
||||
"zoomOut": "Alejar"
|
||||
},
|
||||
"audit": {
|
||||
"account": "Cuenta",
|
||||
"accountPlaceholder": "Seleccionar cuenta",
|
||||
"allAccounts": "Todas las cuentas",
|
||||
"allTypes": "Todos los tipos",
|
||||
"allUsers": "Todos los usuarios",
|
||||
"apply": "Aplicar",
|
||||
"detail": "Detalle",
|
||||
"empty": "No se encontraron eventos de auditoría",
|
||||
"endDate": "Fecha de finalización",
|
||||
"eventType": "Tipo de evento",
|
||||
"eventTypes": {
|
||||
"accessTokenCreated": "Token de acceso creado",
|
||||
"accessTokenRemoved": "Token de acceso eliminado",
|
||||
"accountCreated": "Cuenta creada",
|
||||
"accountDownloadStarted": "Sinc. de cuenta iniciada",
|
||||
"accountDownloadStopped": "Sinc. de cuenta detenida",
|
||||
"accountRemoved": "Cuenta eliminada",
|
||||
"accountRoleAssigned": "Acceso a cuenta asignado",
|
||||
"accountUpdated": "Cuenta actualizada",
|
||||
"attachmentDownloaded": "Adjunto descargado",
|
||||
"attachmentPreviewed": "Adjunto previsualizado",
|
||||
"attachmentTagged": "Etiquetas de adjunto modificadas",
|
||||
"emailDeleted": "Correo eliminado",
|
||||
"emailExported": "Correo exportado",
|
||||
"emailRestored": "Correo restaurado",
|
||||
"emailTagged": "Etiquetas de correo modificadas",
|
||||
"emailViewed": "Correo visto",
|
||||
"importPerformed": "Importación realizada",
|
||||
"licenseUploaded": "Licencia cargada",
|
||||
"mailboxRemoved": "Buzón eliminado",
|
||||
"oauth2Created": "Config. OAuth2 creada",
|
||||
"oauth2Removed": "Config. OAuth2 eliminada",
|
||||
"oauth2TokenStored": "Token OAuth2 guardado",
|
||||
"oauth2Updated": "Config. OAuth2 actualizada",
|
||||
"proxyCreated": "Proxy creado",
|
||||
"proxyRemoved": "Proxy eliminado",
|
||||
"proxyUpdated": "Proxy actualizado",
|
||||
"roleCreated": "Rol creado",
|
||||
"roleRemoved": "Rol eliminado",
|
||||
"roleUpdated": "Rol actualizado",
|
||||
"searchPerformed": "Búsqueda realizada",
|
||||
"settingsChanged": "Ajustes modificados",
|
||||
"ssoLogin": "Inicio de sesión SSO",
|
||||
"ssoLogout": "Cierre de sesión SSO",
|
||||
"userCreated": "Usuario creado",
|
||||
"userLogin": "Inicio de sesión de usuario",
|
||||
"userRemoved": "Usuario eliminado",
|
||||
"userUpdated": "Usuario actualizado"
|
||||
},
|
||||
"forbidden": "El registro de auditoría solo está disponible en la edición Pro.",
|
||||
"hideDetails": "Ocultar detalles",
|
||||
"ip": "IP",
|
||||
"loading": "Cargando...",
|
||||
"noAccounts": "No se encontraron cuentas",
|
||||
"noUsers": "No se encontraron usuarios",
|
||||
"reset": "Restablecer",
|
||||
"showDetails": "Mostrar detalles",
|
||||
"startDate": "Fecha de inicio",
|
||||
"time": "Hora",
|
||||
"title": "Registro de auditoría",
|
||||
"user": "Usuario",
|
||||
"userPlaceholder": "nombre de usuario"
|
||||
},
|
||||
"auth": {
|
||||
"areYouSureYouWantToLogOut": "¿Estás seguro de que quieres cerrar sesión?",
|
||||
"invalidPassword": "Contraseña inválida. Inténtalo de nuevo.",
|
||||
@@ -497,6 +572,7 @@
|
||||
"sessionExpired": "¡Sesión caducada!",
|
||||
"sessionExpiredDesc": "Tu sesión ha caducado debido a la inactividad. Inicia sesión para continuar.",
|
||||
"somethingWentWrong": "Algo salió mal",
|
||||
"ssoLogin": "Inicio de sesión SSO",
|
||||
"username": "Nombre de usuario",
|
||||
"welcome": "Bienvenido a Bichon",
|
||||
"youWillNeedToLogInAgain": "Necesitarás iniciar sesión de nuevo para acceder a tu cuenta."
|
||||
@@ -657,19 +733,22 @@
|
||||
},
|
||||
"import": {
|
||||
"account": "Cuenta",
|
||||
"chooseFiles": "3. Seleccionar archivos",
|
||||
"chooseFiles": "2. Seleccionar archivos",
|
||||
"completed": "Importación completada",
|
||||
"description": "Importar archivos de correo a una cuenta local (NoSync). Para archivos más grandes, use la CLI.",
|
||||
"detectedFolder": "Detectado",
|
||||
"detectedFrom": "Detectado de",
|
||||
"dropHere": "Arrastre archivos .eml / .mbox / .pst aquí",
|
||||
"duplicateCount": "{{count}} duplicados omitidos",
|
||||
"duplicateCountHint": "Estos mensajes ya están archivados",
|
||||
"failed": "Error al importar",
|
||||
"failedCount": "{{count}} fallidos",
|
||||
"failedDetails": "Elementos fallidos",
|
||||
"fileCount": "{{count}} archivos",
|
||||
"folder": "Carpeta",
|
||||
"folderMethod": "2. Elegir método de carpeta",
|
||||
"folderMethod": "3. Elegir método de carpeta",
|
||||
"folderMethodDesc": "¿Cómo se debe determinar la carpeta de correo de destino?",
|
||||
"folderStructure": "2. Estructura de carpetas",
|
||||
"folderStructure": "3. Estructura de carpetas",
|
||||
"importHistory": "Historial de importación",
|
||||
"limits": "Máx: EML 100 MB · MBOX {{maxMbox}} MB · PST {{maxPst}} MB. Archivos más grandes → CLI.",
|
||||
"modeCustom": "Ingresar un nombre de carpeta personalizado",
|
||||
@@ -680,6 +759,7 @@
|
||||
"modeHeaderDesc": "Lee X-Gmail-Labels / X-Bichon-Metadata del archivo. Alternativa: nombre del archivo.",
|
||||
"noAccountFound": "No se encontró ninguna cuenta.",
|
||||
"noFileYet": "Ningún archivo seleccionado",
|
||||
"noFilesSelected": "No hay archivos seleccionados",
|
||||
"noMailboxFound": "No se encontró ningún buzón.",
|
||||
"noMailboxes": "No se encontraron buzones en esta cuenta.",
|
||||
"orClick": "o haga clic para buscar",
|
||||
@@ -690,8 +770,11 @@
|
||||
"searchAccount": "Buscar cuentas...",
|
||||
"searchMailbox": "Buscar buzones...",
|
||||
"selectAccount": "Seleccionar una cuenta",
|
||||
"selectAccountAndFiles": "Seleccione primero una cuenta de destino y los archivos.",
|
||||
"selectAccountFirst": "Seleccione una cuenta primero.",
|
||||
"selectAccountRequired": "Seleccione primero una cuenta de destino.",
|
||||
"selectFileFirst": "Seleccione un archivo primero para determinar las opciones disponibles.",
|
||||
"selectFilesRequired": "Seleccione los archivos que desea importar.",
|
||||
"selectMailbox": "Seleccionar buzón...",
|
||||
"source": "origen",
|
||||
"startImport": "Importar",
|
||||
@@ -702,6 +785,46 @@
|
||||
"uploadingFile": "Subiendo archivo",
|
||||
"willImportTo": "Se importará a"
|
||||
},
|
||||
"license": {
|
||||
"accounts": "Cuentas",
|
||||
"accountsUsed": "{{used}} de {{limit}} usados",
|
||||
"chooseFile": "Elegir archivo",
|
||||
"copied": "Copiado al portapapeles",
|
||||
"copyFailed": "Error al copiar",
|
||||
"copyMachineId": "Copiar ID de la máquina",
|
||||
"description": "Consulte los detalles de su licencia actual y actualice las credenciales.",
|
||||
"edition": "Edición",
|
||||
"features": "Características",
|
||||
"forbidden": "La gestión de licencias solo está disponible en la edición Pro.",
|
||||
"licensee": "Titular de la licencia",
|
||||
"loadFailed": "Error al cargar el estado de la licencia.",
|
||||
"machineIdDesc": "Identificador único de este dispositivo necesario para generar una licencia sin conexión.",
|
||||
"machineIdTitle": "ID de la máquina",
|
||||
"notAvailable": "No disponible",
|
||||
"pasteHere": "Pegue el contenido de la licencia aquí...",
|
||||
"readFileFailed": "Error al leer el archivo",
|
||||
"status": "Estado",
|
||||
"statusDesc": "Detalles de su activación y características actuales",
|
||||
"statusError": "Error de licencia",
|
||||
"statusInvalid": "Firma no válida",
|
||||
"statusMachineMismatch": "El ID de la máquina no coincide",
|
||||
"statusTitle": "Estado de la licencia",
|
||||
"statusTrial": "Prueba",
|
||||
"statusTrialExpired": "Prueba expirada",
|
||||
"statusUpdateExpired": "Periodo de actualización expirado",
|
||||
"statusValid": "Válida",
|
||||
"title": "Gestión de licencias",
|
||||
"trialDays": "Días de prueba",
|
||||
"trialDaysRemaining": "Quedan {{days}} días",
|
||||
"updatesUntil": "Actualizaciones hasta",
|
||||
"upload": "Cargar",
|
||||
"uploadDesc": "Sube tu archivo de licencia o pega el contenido directamente para aplicar las actualizaciones.",
|
||||
"uploadFailed": "Error al cargar la licencia",
|
||||
"uploadFailedDesc": "No se pudo analizar o validar el archivo de licencia.",
|
||||
"uploadSuccess": "Licencia cargada con éxito",
|
||||
"uploadTitle": "Actualizar licencia",
|
||||
"uploading": "Cargando..."
|
||||
},
|
||||
"mail": {
|
||||
"account": "Cuenta",
|
||||
"attachments": "Adjuntos",
|
||||
@@ -791,10 +914,12 @@
|
||||
"accounts": "Cuentas",
|
||||
"apiDocs": "Documentación API",
|
||||
"attachment": "Archivos adjuntos",
|
||||
"auditLog": "Registro de auditoría",
|
||||
"auth": "Autenticación",
|
||||
"dashboard": "Panel de control",
|
||||
"general": "General",
|
||||
"home": "Inicio",
|
||||
"license": "Licencia",
|
||||
"mailbox": "Buzón",
|
||||
"oauth2": "OAuth2",
|
||||
"other": "Otro",
|
||||
@@ -1422,6 +1547,10 @@
|
||||
}
|
||||
},
|
||||
"proxy": "Proxy",
|
||||
"proxyTest": "Probar proxy",
|
||||
"proxyTestFailed": "Error en la conexión del proxy.",
|
||||
"proxyTestSuccess": "¡Conexión de proxy exitosa!",
|
||||
"proxyTesting": "Probando proxy...",
|
||||
"proxyUpdateOrAddFailed": "Error al {{action}} el proxy, por favor, inténtalo de nuevo más tarde",
|
||||
"reset": "Restablecer",
|
||||
"resetRootPassword": "Restablecer contraseña raíz",
|
||||
|
||||
@@ -98,6 +98,7 @@
|
||||
"allMailSkipped": "Oletuskansiot valittu. 'Kaikki sähköpostit' ohitettiin päällekkäisyyksien välttämiseksi.",
|
||||
"areYouSureYouWantTo": "Haluatko varmasti {{action}} tämän tilin?",
|
||||
"auth": "Todennus",
|
||||
"authPassword": "Tunnistautumissalasana",
|
||||
"authType": "todennustyyppi",
|
||||
"autoConfiguring": "Määritetään automaattisesti…",
|
||||
"autoDiscover": "Hae palvelinasetukset automaattisesti",
|
||||
@@ -116,6 +117,7 @@
|
||||
"clickSaveWhenDone": "Napsauta Tallenna, kun olet valmis.",
|
||||
"continue": "Jatka",
|
||||
"createdAt": "Luotu",
|
||||
"creating": "Luodaan tiliä...",
|
||||
"creationFailed": "Luominen epäonnistui, yritä myöhemmin uudelleen",
|
||||
"cronAdvanced": "Edistynyt lauseke",
|
||||
"cronDaily": "Päivittäin",
|
||||
@@ -333,18 +335,26 @@
|
||||
"selectedMailboxes": "Valitut postilaatikot",
|
||||
"serverConfiguration": "Palvelinmääritys (IMAP)",
|
||||
"settings": {
|
||||
"backToAccounts": "Takaisin tileihin",
|
||||
"download": "Lataus",
|
||||
"downloadDesc": "Määritä, milloin ja miten sähköpostit haetaan palvelimelta.",
|
||||
"filters": "Suodattimet",
|
||||
"filtersDesc": "Hallitse, mitkä sähköpostit arkistoidaan. Kun suodatus on poissa päältä, kaikki sähköpostit tallennetaan.",
|
||||
"general": "Yleiset",
|
||||
"generalDesc": "Tilin perustiedot ja tila.",
|
||||
"loading": "Ladataan asetuksia...",
|
||||
"newAccount": "Uusi tili",
|
||||
"performance": "Suorituskyky",
|
||||
"reset": "Palauta asetukset",
|
||||
"save": "Tallenna asetukset",
|
||||
"saved": "Tallennettu",
|
||||
"savedDesc": "Tilin asetukset tallennettu onnistuneesti.",
|
||||
"saving": "Tallennetaan asetuksia...",
|
||||
"schedule": "Aikataulu",
|
||||
"scope": "Laajuus",
|
||||
"server": "Palvelin",
|
||||
"serverDesc": "IMAP-yhteysasetukset ja todennus."
|
||||
"serverDesc": "IMAP-yhteysasetukset ja todennus.",
|
||||
"settings": "Tilin asetukset"
|
||||
},
|
||||
"since": "alkaen",
|
||||
"sinceFixed": "Tietystä päivämäärästä lähtien",
|
||||
@@ -470,6 +480,7 @@
|
||||
"downloading": "Ladataan...",
|
||||
"emailMessageNotFound": "Alkuperäistä sähköpostia ei löytynyt. Se on ehkä poistettu.",
|
||||
"name": "Tiedostonimi",
|
||||
"preview": "Esikatsele liite",
|
||||
"search_input_placeholder": "Hae liitteitä (käytä \" \" lausehakuun)",
|
||||
"sender": "Lähettäjä",
|
||||
"sender_with_count": "Lähettäjä ({{count}})",
|
||||
@@ -487,6 +498,70 @@
|
||||
"zoomIn": "Lähennä",
|
||||
"zoomOut": "Loitonna"
|
||||
},
|
||||
"audit": {
|
||||
"account": "Tili",
|
||||
"accountPlaceholder": "Valitse tili",
|
||||
"allAccounts": "Kaikki tilit",
|
||||
"allTypes": "Kaikki tyypit",
|
||||
"allUsers": "Kaikki käyttäjät",
|
||||
"apply": "Käytä",
|
||||
"detail": "Tiedot",
|
||||
"empty": "Tarkastustapahtumia ei löytynyt",
|
||||
"endDate": "Päättymispäivämäärä",
|
||||
"eventType": "Tapahtumatyyppi",
|
||||
"eventTypes": {
|
||||
"accessTokenCreated": "Käyttöavain luotu",
|
||||
"accessTokenRemoved": "Käyttöavain poistettu",
|
||||
"accountCreated": "Tili luotu",
|
||||
"accountDownloadStarted": "Tilin synkronointi aloitettu",
|
||||
"accountDownloadStopped": "Tilin synkronointi pysäytetty",
|
||||
"accountRemoved": "Tili poistettu",
|
||||
"accountRoleAssigned": "Tilin käyttöoikeus määritetty",
|
||||
"accountUpdated": "Tili päivitetty",
|
||||
"attachmentDownloaded": "Liite ladattu",
|
||||
"attachmentPreviewed": "Liite esikatseltu",
|
||||
"attachmentTagged": "Liitteen tunnisteet muutettu",
|
||||
"emailDeleted": "Sähköposti poistettu",
|
||||
"emailExported": "Sähköposti viety",
|
||||
"emailRestored": "Sähköposti palautettu",
|
||||
"emailTagged": "Sähköpostin tunnisteet muutettu",
|
||||
"emailViewed": "Sähköposti katsottu",
|
||||
"importPerformed": "Tuonti suoritettu",
|
||||
"licenseUploaded": "Lisenssi ladattu",
|
||||
"mailboxRemoved": "Postilaatikko poistettu",
|
||||
"oauth2Created": "OAuth2-asetukset luotu",
|
||||
"oauth2Removed": "OAuth2-asetukset poistettu",
|
||||
"oauth2TokenStored": "OAuth2-tunniste tallennettu",
|
||||
"oauth2Updated": "OAuth2-asetukset päivitetty",
|
||||
"proxyCreated": "Välityspalvelin luotu",
|
||||
"proxyRemoved": "Välityspalvelin poistettu",
|
||||
"proxyUpdated": "Välityspalvelin päivitetty",
|
||||
"roleCreated": "Rooli luotu",
|
||||
"roleRemoved": "Rooli poistettu",
|
||||
"roleUpdated": "Rooli päivitetty",
|
||||
"searchPerformed": "Haku suoritettu",
|
||||
"settingsChanged": "Asetuksia muutettu",
|
||||
"ssoLogin": "SSO-kirjautuminen",
|
||||
"ssoLogout": "SSO-uloskirjautuminen",
|
||||
"userCreated": "Käyttäjä luotu",
|
||||
"userLogin": "Käyttäjän kirjautuminen",
|
||||
"userRemoved": "Käyttäjä poistettu",
|
||||
"userUpdated": "Käyttäjä päivitetty"
|
||||
},
|
||||
"forbidden": "Tarkastusloki on saatavilla vain Pro-versiossa.",
|
||||
"hideDetails": "Piilota tiedot",
|
||||
"ip": "IP",
|
||||
"loading": "Ladataan...",
|
||||
"noAccounts": "Tilejä ei löytynyt",
|
||||
"noUsers": "Käyttäjiä ei löytynyt",
|
||||
"reset": "Nollaa",
|
||||
"showDetails": "Näytä tiedot",
|
||||
"startDate": "Aloituspäivämäärä",
|
||||
"time": "Aika",
|
||||
"title": "Tarkastusloki",
|
||||
"user": "Käyttäjä",
|
||||
"userPlaceholder": "käyttäjätunnus"
|
||||
},
|
||||
"auth": {
|
||||
"areYouSureYouWantToLogOut": "Oletko varma, että haluat kirjautua ulos?",
|
||||
"invalidPassword": "Virheellinen salasana. Yritä uudelleen.",
|
||||
@@ -497,6 +572,7 @@
|
||||
"sessionExpired": "Istunto vanhentunut!",
|
||||
"sessionExpiredDesc": "Istuntosi on päättynyt toimettomuuden vuoksi. Kirjaudu sisään jatkaaksesi.",
|
||||
"somethingWentWrong": "Jotain meni vikaan",
|
||||
"ssoLogin": "SSO-kirjautuminen",
|
||||
"username": "Käyttäjänimi",
|
||||
"welcome": "Tervetuloa Bichoniin",
|
||||
"youWillNeedToLogInAgain": "Sinun on kirjauduttava sisään uudelleen päästäksesi tilillesi."
|
||||
@@ -657,19 +733,22 @@
|
||||
},
|
||||
"import": {
|
||||
"account": "Tili",
|
||||
"chooseFiles": "3. Valitse tiedostot",
|
||||
"chooseFiles": "2. Valitse tiedostot",
|
||||
"completed": "Tuonti valmis",
|
||||
"description": "Tuo sähköpostitiedostoja paikalliselle tilille (NoSync). Käytä CLI:tä suuremmille tiedostoille.",
|
||||
"detectedFolder": "Tunnistettu",
|
||||
"detectedFrom": "Tunnistettu lähteestä",
|
||||
"dropHere": "Pudota .eml / .mbox / .pst -tiedostot tähän",
|
||||
"duplicateCount": "{{count}} kaksoiskappaletta ohitettu",
|
||||
"duplicateCountHint": "Nämä viestit on jo arkistoitu",
|
||||
"failed": "Tuonti epäonnistui",
|
||||
"failedCount": "{{count}} epäonnistui",
|
||||
"failedDetails": "Epäonnistuneet kohteet",
|
||||
"fileCount": "{{count}} tiedostoa",
|
||||
"folder": "Kansio",
|
||||
"folderMethod": "2. Valitse kansiomenetelmä",
|
||||
"folderMethod": "3. Valitse kansiomenetelmä",
|
||||
"folderMethodDesc": "Miten kohdekansio tulisi määrittää?",
|
||||
"folderStructure": "2. Kansionrakenne",
|
||||
"folderStructure": "3. Kansionrakenne",
|
||||
"importHistory": "Tuontihistoria",
|
||||
"limits": "Max: EML 100 MB · MBOX {{maxMbox}} MB · PST {{maxPst}} MB. Suuremmat tiedostot → CLI.",
|
||||
"modeCustom": "Syötä mukautettu kansion nimi",
|
||||
@@ -680,6 +759,7 @@
|
||||
"modeHeaderDesc": "Lue X-Gmail-Labels / X-Bichon-Metadata tiedostosta. Varajärjestelmänä tiedostonimi.",
|
||||
"noAccountFound": "Tiliä ei löytynyt.",
|
||||
"noFileYet": "Ei valittua tiedostoa",
|
||||
"noFilesSelected": "Ei valittuja tiedostoja",
|
||||
"noMailboxFound": "Postilaatikkoa ei löytynyt.",
|
||||
"noMailboxes": "Tältä tililtä ei löytynyt postilaatikoita.",
|
||||
"orClick": "tai napsauta selataksesi",
|
||||
@@ -690,8 +770,11 @@
|
||||
"searchAccount": "Etsi tilejä...",
|
||||
"searchMailbox": "Etsi postilaatikoita...",
|
||||
"selectAccount": "Valitse tili",
|
||||
"selectAccountAndFiles": "Valitse ensin kohdetili ja tiedostot.",
|
||||
"selectAccountFirst": "Valitse ensin tili.",
|
||||
"selectAccountRequired": "Valitse ensin kohdetili.",
|
||||
"selectFileFirst": "Valitse ensin tiedosto määrittääksesi käytettävissä olevat vaihtoehdot.",
|
||||
"selectFilesRequired": "Valitse tuotavat tiedostot.",
|
||||
"selectMailbox": "Valitse postilaatikko...",
|
||||
"source": "lähde",
|
||||
"startImport": "Tuo",
|
||||
@@ -702,6 +785,46 @@
|
||||
"uploadingFile": "Ladataan tiedostoa",
|
||||
"willImportTo": "Tuodaan kohteeseen"
|
||||
},
|
||||
"license": {
|
||||
"accounts": "Tilit",
|
||||
"accountsUsed": "{{used}} / {{limit}} käytössä",
|
||||
"chooseFile": "Valitse tiedosto",
|
||||
"copied": "Kopioitu leikepöydälle",
|
||||
"copyFailed": "Kopiointi epäonnistui",
|
||||
"copyMachineId": "Kopioi laitetunniste",
|
||||
"description": "Tarkastele nykyisiä lisenstitietojasi ja päivitä tunnukset.",
|
||||
"edition": "Versio",
|
||||
"features": "Ominaisuudet",
|
||||
"forbidden": "Lisenssien hallinta on saatavilla vain Pro-versiossa.",
|
||||
"licensee": "Lisenssinhaltija",
|
||||
"loadFailed": "Lisenstitilan lataaminen epäonnistui.",
|
||||
"machineIdDesc": "Tämän laitteen yksilöllinen tunniste, joka vaaditaan offline-lisenssin luomiseen.",
|
||||
"machineIdTitle": "Laitetunniste",
|
||||
"notAvailable": "Ei saatavilla",
|
||||
"pasteHere": "Liitä lisenssin sisältö tähän...",
|
||||
"readFileFailed": "Tiedoston lukeminen epäonnistui",
|
||||
"status": "Tila",
|
||||
"statusDesc": "Nykyiset aktivointi- ja ominaisuustietosi",
|
||||
"statusError": "Lisenssivirhe",
|
||||
"statusInvalid": "Virheellinen allekirjoitus",
|
||||
"statusMachineMismatch": "Laitetunniste ei täsmää",
|
||||
"statusTitle": "Lisenssitila",
|
||||
"statusTrial": "Kokeiluversio",
|
||||
"statusTrialExpired": "Kokeiluaika päättynyt",
|
||||
"statusUpdateExpired": "Päivitysoikeus päättynyt",
|
||||
"statusValid": "Voimassa",
|
||||
"title": "Lisenssien hallinta",
|
||||
"trialDays": "Kokeilupäivät",
|
||||
"trialDaysRemaining": "{{days}} päivää jäljellä",
|
||||
"updatesUntil": "Päivitykset asti",
|
||||
"upload": "Lataa",
|
||||
"uploadDesc": "Lataa lisenssitiedostosi tai liitä sisältö suoraan päivitysten ottamiseksi käyttöön.",
|
||||
"uploadFailed": "Lisenssin lataus epäonnistui",
|
||||
"uploadFailedDesc": "Lisenssitiedostoa ei voitu jäsentää tai vahvistaa.",
|
||||
"uploadSuccess": "Lisenssi ladattiinnistuneesti",
|
||||
"uploadTitle": "Päivitä lisenssi",
|
||||
"uploading": "Ladataan..."
|
||||
},
|
||||
"mail": {
|
||||
"account": "Tili",
|
||||
"attachments": "Liitteet",
|
||||
@@ -791,10 +914,12 @@
|
||||
"accounts": "Tilit",
|
||||
"apiDocs": "API-dokumentaatio",
|
||||
"attachment": "Liitteet",
|
||||
"auditLog": "Tarkastusloki",
|
||||
"auth": "Todennus",
|
||||
"dashboard": "Kojelauta",
|
||||
"general": "Yleinen",
|
||||
"home": "Koti",
|
||||
"license": "Lisenssi",
|
||||
"mailbox": "Sähköposti",
|
||||
"oauth2": "OAuth2",
|
||||
"other": "Muu",
|
||||
@@ -1422,6 +1547,10 @@
|
||||
}
|
||||
},
|
||||
"proxy": "Välityspalvelin",
|
||||
"proxyTest": "Testaa välityspalvelin",
|
||||
"proxyTestFailed": "Välityspalvelinyhteys epäonnistui.",
|
||||
"proxyTestSuccess": "Välityspalvelinyhteys onnistui!",
|
||||
"proxyTesting": "Testataan välityspalvelinta...",
|
||||
"proxyUpdateOrAddFailed": "{{action}} epäonnistui, yritä myöhemmin uudelleen",
|
||||
"reset": "Nollaa",
|
||||
"resetRootPassword": "Nollaa pääkäyttäjän salasana",
|
||||
|
||||
@@ -98,6 +98,7 @@
|
||||
"allMailSkipped": "Dossiers par défaut sélectionnés. 'Tous les messages' a été ignoré pour éviter les doublons.",
|
||||
"areYouSureYouWantTo": "Êtes-vous sûr de vouloir {{action}} ce compte ?",
|
||||
"auth": "Auth.",
|
||||
"authPassword": "Mot de passe d'authentification",
|
||||
"authType": "type_auth",
|
||||
"autoConfiguring": "Configuration automatique…",
|
||||
"autoDiscover": "Détection automatique des paramètres du serveur",
|
||||
@@ -116,6 +117,7 @@
|
||||
"clickSaveWhenDone": "Cliquez sur Enregistrer lorsque vous avez terminé.",
|
||||
"continue": "Continuer",
|
||||
"createdAt": "Créé le",
|
||||
"creating": "Création du compte...",
|
||||
"creationFailed": "La création a échoué, veuillez réessayer plus tard",
|
||||
"cronAdvanced": "Expression avancée",
|
||||
"cronDaily": "Chaque jour",
|
||||
@@ -333,18 +335,26 @@
|
||||
"selectedMailboxes": "Boîtes mail sélectionnées",
|
||||
"serverConfiguration": "Configuration du Serveur (IMAP)",
|
||||
"settings": {
|
||||
"backToAccounts": "Retour aux comptes",
|
||||
"download": "Téléchargement",
|
||||
"downloadDesc": "Configurer quand et comment les e-mails sont récupérés depuis le serveur.",
|
||||
"filters": "Filtres",
|
||||
"filtersDesc": "Contrôlez quels e-mails sont archivés. Si le filtrage est désactivé, tous les e-mails seront enregistrés.",
|
||||
"general": "Général",
|
||||
"generalDesc": "Informations de base sur le compte et statut.",
|
||||
"loading": "Chargement des paramètres...",
|
||||
"newAccount": "Nouveau compte",
|
||||
"performance": "Performances",
|
||||
"reset": "Réinitialiser les paramètres",
|
||||
"save": "Enregistrer les paramètres",
|
||||
"saved": "Enregistré",
|
||||
"savedDesc": "Paramètres du compte enregistrés avec succès.",
|
||||
"saving": "Enregistrement des paramètres...",
|
||||
"schedule": "Planification",
|
||||
"scope": "Période",
|
||||
"server": "Serveur",
|
||||
"serverDesc": "Paramètres de connexion IMAP et authentification."
|
||||
"serverDesc": "Paramètres de connexion IMAP et authentification.",
|
||||
"settings": "Paramètres du compte"
|
||||
},
|
||||
"since": "depuis",
|
||||
"sinceFixed": "Depuis une date spécifique",
|
||||
@@ -470,6 +480,7 @@
|
||||
"downloading": "Téléchargement en cours...",
|
||||
"emailMessageNotFound": "Impossible de trouver l'e-mail original. Il a peut-être été supprimé.",
|
||||
"name": "Nom du fichier",
|
||||
"preview": "Aperçu de la pièce jointe",
|
||||
"search_input_placeholder": "Rechercher des pièces jointes (utilisez \" \" pour la recherche par expression)",
|
||||
"sender": "Expéditeur",
|
||||
"sender_with_count": "Expéditeur ({{count}})",
|
||||
@@ -487,6 +498,70 @@
|
||||
"zoomIn": "Zoom avant",
|
||||
"zoomOut": "Zoom arrière"
|
||||
},
|
||||
"audit": {
|
||||
"account": "Compte",
|
||||
"accountPlaceholder": "Sélectionner un compte",
|
||||
"allAccounts": "Tous les comptes",
|
||||
"allTypes": "Tous les types",
|
||||
"allUsers": "Tous les utilisateurs",
|
||||
"apply": "Appliquer",
|
||||
"detail": "Détail",
|
||||
"empty": "Aucun événement d'audit trouvé",
|
||||
"endDate": "Date de fin",
|
||||
"eventType": "Type d'événement",
|
||||
"eventTypes": {
|
||||
"accessTokenCreated": "Jeton d'accès créé",
|
||||
"accessTokenRemoved": "Jeton d'accès supprimé",
|
||||
"accountCreated": "Compte créé",
|
||||
"accountDownloadStarted": "Synchro du compte démarrée",
|
||||
"accountDownloadStopped": "Synchro du compte arrêtée",
|
||||
"accountRemoved": "Compte supprimé",
|
||||
"accountRoleAssigned": "Accès au compte attribué",
|
||||
"accountUpdated": "Compte mis à jour",
|
||||
"attachmentDownloaded": "Pièce jointe téléchargée",
|
||||
"attachmentPreviewed": "Pièce jointe prévisualisée",
|
||||
"attachmentTagged": "Étiquettes de la pièce jointe modifiées",
|
||||
"emailDeleted": "E-mail supprimé",
|
||||
"emailExported": "E-mail exporté",
|
||||
"emailRestored": "E-mail restauré",
|
||||
"emailTagged": "Étiquettes de l'e-mail modifiées",
|
||||
"emailViewed": "E-mail consulté",
|
||||
"importPerformed": "Importation effectuée",
|
||||
"licenseUploaded": "Licence téléversée",
|
||||
"mailboxRemoved": "Boîte aux lettres supprimée",
|
||||
"oauth2Created": "Config. OAuth2 créée",
|
||||
"oauth2Removed": "Config. OAuth2 supprimée",
|
||||
"oauth2TokenStored": "Jeton OAuth2 stocké",
|
||||
"oauth2Updated": "Config. OAuth2 mise à jour",
|
||||
"proxyCreated": "Proxy créé",
|
||||
"proxyRemoved": "Proxy supprimé",
|
||||
"proxyUpdated": "Proxy mis à jour",
|
||||
"roleCreated": "Rôle créé",
|
||||
"roleRemoved": "Rôle supprimé",
|
||||
"roleUpdated": "Rôle mis à jour",
|
||||
"searchPerformed": "Recherche effectuée",
|
||||
"settingsChanged": "Paramètres modifiés",
|
||||
"ssoLogin": "Connexion SSO",
|
||||
"ssoLogout": "Déconnexion SSO",
|
||||
"userCreated": "Utilisateur créé",
|
||||
"userLogin": "Connexion utilisateur",
|
||||
"userRemoved": "Utilisateur supprimé",
|
||||
"userUpdated": "Utilisateur mis à jour"
|
||||
},
|
||||
"forbidden": "Le journal d'audit est disponible uniquement dans l'édition Pro.",
|
||||
"hideDetails": "Masquer les détails",
|
||||
"ip": "IP",
|
||||
"loading": "Chargement...",
|
||||
"noAccounts": "Aucun compte trouvé",
|
||||
"noUsers": "Aucun utilisateur trouvé",
|
||||
"reset": "Réinitialiser",
|
||||
"showDetails": "Afficher les détails",
|
||||
"startDate": "Date de début",
|
||||
"time": "Heure",
|
||||
"title": "Journal d'audit",
|
||||
"user": "Utilisateur",
|
||||
"userPlaceholder": "nom d'utilisateur"
|
||||
},
|
||||
"auth": {
|
||||
"areYouSureYouWantToLogOut": "Êtes-vous sûr de vouloir vous déconnecter ?",
|
||||
"invalidPassword": "Mot de passe non valide. Veuillez réessayer.",
|
||||
@@ -497,6 +572,7 @@
|
||||
"sessionExpired": "Session expirée !",
|
||||
"sessionExpiredDesc": "Votre session a été fermée en raison de l'inactivité. Veuillez vous reconnecter pour continuer.",
|
||||
"somethingWentWrong": "Quelque chose s'est mal passé",
|
||||
"ssoLogin": "Connexion SSO",
|
||||
"username": "Nom d'utilisateur",
|
||||
"welcome": "Bienvenue sur Bichon",
|
||||
"youWillNeedToLogInAgain": "Vous devrez vous reconnecter pour accéder à votre compte."
|
||||
@@ -657,19 +733,22 @@
|
||||
},
|
||||
"import": {
|
||||
"account": "Compte",
|
||||
"chooseFiles": "3. Choisir les fichiers",
|
||||
"chooseFiles": "2. Choisir les fichiers",
|
||||
"completed": "Importation terminée",
|
||||
"description": "Importer des fichiers d'e-mails dans un compte local (NoSync). Pour les gros fichiers, utilisez le CLI.",
|
||||
"detectedFolder": "Détecté",
|
||||
"detectedFrom": "Détecté depuis",
|
||||
"dropHere": "Déposez les fichiers .eml / .mbox / .pst ici",
|
||||
"duplicateCount": "{{count}} doublon(s) ignoré(s)",
|
||||
"duplicateCountHint": "Ces messages sont déjà archivés",
|
||||
"failed": "Échec de l'importation",
|
||||
"failedCount": "{{count}} échoué(s)",
|
||||
"failedDetails": "Éléments en échec",
|
||||
"fileCount": "{{count}} fichiers",
|
||||
"folder": "Dossier",
|
||||
"folderMethod": "2. Choisir la méthode de dossier",
|
||||
"folderMethod": "3. Choisir la méthode de dossier",
|
||||
"folderMethodDesc": "Comment le dossier de destination doit-il être déterminé ?",
|
||||
"folderStructure": "2. Structure des dossiers",
|
||||
"folderStructure": "3. Structure des dossiers",
|
||||
"importHistory": "Historique d'importation",
|
||||
"limits": "Max : EML 100 MB · MBOX {{maxMbox}} MB · PST {{maxPst}} MB. Fichiers plus volumineux → CLI.",
|
||||
"modeCustom": "Saisir un nom de dossier personnalisé",
|
||||
@@ -680,6 +759,7 @@
|
||||
"modeHeaderDesc": "Lit X-Gmail-Labels / X-Bichon-Metadata depuis le fichier. Alternative : nom du fichier.",
|
||||
"noAccountFound": "Aucun compte trouvé.",
|
||||
"noFileYet": "Aucun fichier sélectionné",
|
||||
"noFilesSelected": "Aucun fichier sélectionné",
|
||||
"noMailboxFound": "Aucune boîte aux lettres trouvée.",
|
||||
"noMailboxes": "Aucune boîte aux lettres trouvée dans ce compte.",
|
||||
"orClick": "ou cliquez pour parcourir",
|
||||
@@ -690,8 +770,11 @@
|
||||
"searchAccount": "Rechercher des comptes...",
|
||||
"searchMailbox": "Rechercher des boîtes...",
|
||||
"selectAccount": "Sélectionner un compte",
|
||||
"selectAccountAndFiles": "Veuillez d'abord sélectionner un compte cible et des fichiers.",
|
||||
"selectAccountFirst": "Sélectionnez d'abord un compte.",
|
||||
"selectAccountRequired": "Veuillez d'abord sélectionner un compte cible.",
|
||||
"selectFileFirst": "Sélectionnez d'abord un fichier si vous souhaitez déterminer les options disponibles.",
|
||||
"selectFilesRequired": "Veuillez sélectionner les fichiers à importer.",
|
||||
"selectMailbox": "Sélectionner une boîte...",
|
||||
"source": "source",
|
||||
"startImport": "Importer",
|
||||
@@ -702,6 +785,46 @@
|
||||
"uploadingFile": "Téléversement du fichier",
|
||||
"willImportTo": "Sera importé dans"
|
||||
},
|
||||
"license": {
|
||||
"accounts": "Comptes",
|
||||
"accountsUsed": "{{used}} sur {{limit}} utilisés",
|
||||
"chooseFile": "Choisir un fichier",
|
||||
"copied": "Copié dans le presse-papiers",
|
||||
"copyFailed": "Échec de la copie",
|
||||
"copyMachineId": "Copier l'identifiant de machine",
|
||||
"description": "Affichez les détails de votre licence actuelle et mettez à jour les informations d'identification.",
|
||||
"edition": "Édition",
|
||||
"features": "Fonctionnalités",
|
||||
"forbidden": "La gestion des licences est disponible uniquement dans l'édition Pro.",
|
||||
"licensee": "Titulaire de la licence",
|
||||
"loadFailed": "Échec du chargement de l'état de la licence.",
|
||||
"machineIdDesc": "Identifiant unique de cet appareil requis pour générer une licence hors ligne.",
|
||||
"machineIdTitle": "Identifiant de machine",
|
||||
"notAvailable": "Non disponible",
|
||||
"pasteHere": "Collez le contenu de la licence ici...",
|
||||
"readFileFailed": "Échec de la lecture du fichier",
|
||||
"status": "Statut",
|
||||
"statusDesc": "Détails de votre activation et de vos fonctionnalités actuelles",
|
||||
"statusError": "Erreur de licence",
|
||||
"statusInvalid": "Signature invalide",
|
||||
"statusMachineMismatch": "Identifiant de machine non correspondant",
|
||||
"statusTitle": "État de la licence",
|
||||
"statusTrial": "Essai",
|
||||
"statusTrialExpired": "Période d'essai expirée",
|
||||
"statusUpdateExpired": "Mises à jour expirées",
|
||||
"statusValid": "Valide",
|
||||
"title": "Gestion des licences",
|
||||
"trialDays": "Jours d'essai",
|
||||
"trialDaysRemaining": "{{days}} jours restants",
|
||||
"updatesUntil": "Mises à jour jusqu'au",
|
||||
"upload": "Télécharger",
|
||||
"uploadDesc": "Téléchargez votre fichier de licence ou collez directement le contenu pour appliquer les mises à jour.",
|
||||
"uploadFailed": "Échec du téléchargement de la licence",
|
||||
"uploadFailedDesc": "Impossible d'analyser ou de valider le fichier de licence.",
|
||||
"uploadSuccess": "Licence téléchargée avec succès",
|
||||
"uploadTitle": "Mettre à jour la licence",
|
||||
"uploading": "Téléchargement..."
|
||||
},
|
||||
"mail": {
|
||||
"account": "Compte",
|
||||
"attachments": "Pièces jointes",
|
||||
@@ -791,10 +914,12 @@
|
||||
"accounts": "Comptes",
|
||||
"apiDocs": "Documentation API",
|
||||
"attachment": "Pièces jointes",
|
||||
"auditLog": "Journal d'audit",
|
||||
"auth": "Authentification",
|
||||
"dashboard": "Tableau de bord",
|
||||
"general": "Général",
|
||||
"home": "Accueil",
|
||||
"license": "Licence",
|
||||
"mailbox": "Boîte aux lettres",
|
||||
"oauth2": "OAuth2",
|
||||
"other": "Autre",
|
||||
@@ -1422,6 +1547,10 @@
|
||||
}
|
||||
},
|
||||
"proxy": "Proxy",
|
||||
"proxyTest": "Tester le proxy",
|
||||
"proxyTestFailed": "Échec de la connexion proxy.",
|
||||
"proxyTestSuccess": "Connexion proxy réussie !",
|
||||
"proxyTesting": "Test du proxy...",
|
||||
"proxyUpdateOrAddFailed": "La {{action}} a échoué, veuillez réessayer plus tard",
|
||||
"reset": "Réinitialiser",
|
||||
"resetRootPassword": "Réinitialiser le Mot de Passe Root",
|
||||
|
||||
@@ -98,6 +98,7 @@
|
||||
"allMailSkipped": "Cartelle predefinite selezionate. 'Tutta la Posta' è stata saltata per evitare duplicati.",
|
||||
"areYouSureYouWantTo": "Sei sicuro di voler {{action}} questo account?",
|
||||
"auth": "Autenticazione",
|
||||
"authPassword": "Password di autenticazione",
|
||||
"authType": "tipo_autenticazione",
|
||||
"autoConfiguring": "Configurazione automatica…",
|
||||
"autoDiscover": "Rilevamento automatico impostazioni server",
|
||||
@@ -116,6 +117,7 @@
|
||||
"clickSaveWhenDone": "Clicca su Salva quando hai finito.",
|
||||
"continue": "Continua",
|
||||
"createdAt": "Creato Il",
|
||||
"creating": "Creazione account in corso...",
|
||||
"creationFailed": "Creazione fallita, riprova più tardi",
|
||||
"cronAdvanced": "Espressione avanzata",
|
||||
"cronDaily": "Ogni giorno",
|
||||
@@ -333,18 +335,26 @@
|
||||
"selectedMailboxes": "Caselle selezionate",
|
||||
"serverConfiguration": "Configurazione Server (IMAP)",
|
||||
"settings": {
|
||||
"backToAccounts": "Torna agli account",
|
||||
"download": "Download",
|
||||
"downloadDesc": "Configura quando e come le email vengono scaricate dal server.",
|
||||
"filters": "Filtri",
|
||||
"filtersDesc": "Controlla quali email archiviare. Quando il filtraggio è disattivato, vengono salvate tutte le email.",
|
||||
"general": "Generale",
|
||||
"generalDesc": "Informazioni di base sull'account e stato.",
|
||||
"loading": "Caricamento impostazioni...",
|
||||
"newAccount": "Nuovo account",
|
||||
"performance": "Prestazioni",
|
||||
"reset": "Ripristina impostazioni",
|
||||
"save": "Salva impostazioni",
|
||||
"saved": "Salvato",
|
||||
"savedDesc": "Impostazioni dell'account salvate con successo.",
|
||||
"saving": "Salvataggio impostazioni...",
|
||||
"schedule": "Pianificazione",
|
||||
"scope": "Ambito",
|
||||
"server": "Server",
|
||||
"serverDesc": "Impostazioni di connessione IMAP e autenticazione."
|
||||
"serverDesc": "Impostazioni di connessione IMAP e autenticazione.",
|
||||
"settings": "Impostazioni account"
|
||||
},
|
||||
"since": "da",
|
||||
"sinceFixed": "Da una data specifica",
|
||||
@@ -470,6 +480,7 @@
|
||||
"downloading": "Download in corso...",
|
||||
"emailMessageNotFound": "Impossibile trovare l'email originale. Potrebbe essere stata eliminata.",
|
||||
"name": "Nome file",
|
||||
"preview": "Anteprima allegato",
|
||||
"search_input_placeholder": "Cerca allegati (usa \" \" per la ricerca di frasi)",
|
||||
"sender": "Mittente",
|
||||
"sender_with_count": "Mittente ({{count}})",
|
||||
@@ -487,6 +498,70 @@
|
||||
"zoomIn": "Ingrandisci",
|
||||
"zoomOut": "Rimpicciolisci"
|
||||
},
|
||||
"audit": {
|
||||
"account": "Account",
|
||||
"accountPlaceholder": "Seleziona account",
|
||||
"allAccounts": "Tutti gli account",
|
||||
"allTypes": "Tutti i tipi",
|
||||
"allUsers": "Tutti gli utenti",
|
||||
"apply": "Applica",
|
||||
"detail": "Dettaglio",
|
||||
"empty": "Nessun evento di controllo trovato",
|
||||
"endDate": "Data di fine",
|
||||
"eventType": "Tipo di evento",
|
||||
"eventTypes": {
|
||||
"accessTokenCreated": "Token di accesso creato",
|
||||
"accessTokenRemoved": "Token di accesso rimosso",
|
||||
"accountCreated": "Account creato",
|
||||
"accountDownloadStarted": "Sinc. account avviata",
|
||||
"accountDownloadStopped": "Sinc. account interrotta",
|
||||
"accountRemoved": "Account rimosso",
|
||||
"accountRoleAssigned": "Accesso account assegnato",
|
||||
"accountUpdated": "Account aggiornato",
|
||||
"attachmentDownloaded": "Allegato scaricato",
|
||||
"attachmentPreviewed": "Allegato anteposto",
|
||||
"attachmentTagged": "Tag allegato modificati",
|
||||
"emailDeleted": "Email eliminata",
|
||||
"emailExported": "Email esportata",
|
||||
"emailRestored": "Email ripristinata",
|
||||
"emailTagged": "Tag email modificati",
|
||||
"emailViewed": "Email visualizzata",
|
||||
"importPerformed": "Importazione eseguita",
|
||||
"licenseUploaded": "Licenza caricata",
|
||||
"mailboxRemoved": "Casella di posta rimossa",
|
||||
"oauth2Created": "Config. OAuth2 creata",
|
||||
"oauth2Removed": "Config. OAuth2 rimossa",
|
||||
"oauth2TokenStored": "Token OAuth2 salvato",
|
||||
"oauth2Updated": "Config. OAuth2 aggiornata",
|
||||
"proxyCreated": "Proxy creato",
|
||||
"proxyRemoved": "Proxy rimosso",
|
||||
"proxyUpdated": "Proxy aggiornato",
|
||||
"roleCreated": "Ruolo creato",
|
||||
"roleRemoved": "Ruolo rimosso",
|
||||
"roleUpdated": "Ruolo aggiornato",
|
||||
"searchPerformed": "Ricerca eseguita",
|
||||
"settingsChanged": "Impostazioni modificate",
|
||||
"ssoLogin": "Accesso SSO",
|
||||
"ssoLogout": "Uscita SSO",
|
||||
"userCreated": "Utente creato",
|
||||
"userLogin": "Accesso utente",
|
||||
"userRemoved": "Utente rimosso",
|
||||
"userUpdated": "Utente aggiornato"
|
||||
},
|
||||
"forbidden": "Il registro di controllo è disponibile solo nella versione Pro.",
|
||||
"hideDetails": "Nascondi dettagli",
|
||||
"ip": "IP",
|
||||
"loading": "Caricamento...",
|
||||
"noAccounts": "Nessun account trovato",
|
||||
"noUsers": "Nessun utente trovato",
|
||||
"reset": "Reimposta",
|
||||
"showDetails": "Mostra dettagli",
|
||||
"startDate": "Data di inizio",
|
||||
"time": "Ora",
|
||||
"title": "Registro di controllo",
|
||||
"user": "Utente",
|
||||
"userPlaceholder": "nome utente"
|
||||
},
|
||||
"auth": {
|
||||
"areYouSureYouWantToLogOut": "Sei sicuro di voler uscire?",
|
||||
"invalidPassword": "Password non valida. Riprova.",
|
||||
@@ -497,6 +572,7 @@
|
||||
"sessionExpired": "Sessione scaduta!",
|
||||
"sessionExpiredDesc": "La tua sessione è terminata per inattività. Esegui nuovamente l'accesso per continuare.",
|
||||
"somethingWentWrong": "Qualcosa è andato storto",
|
||||
"ssoLogin": "Accesso SSO",
|
||||
"username": "Nome utente",
|
||||
"welcome": "Benvenuto in Bichon",
|
||||
"youWillNeedToLogInAgain": "Dovrai accedere nuovamente per accedere al tuo account."
|
||||
@@ -657,19 +733,22 @@
|
||||
},
|
||||
"import": {
|
||||
"account": "Account",
|
||||
"chooseFiles": "3. Scegli i file",
|
||||
"chooseFiles": "2. Scegli i file",
|
||||
"completed": "Importazione completata",
|
||||
"description": "Importa file email in un account locale (NoSync). Per file più grandi, usa la CLI.",
|
||||
"detectedFolder": "Rilevato",
|
||||
"detectedFrom": "Rilevato da",
|
||||
"dropHere": "Trascina i file .eml / .mbox / .pst qui",
|
||||
"duplicateCount": "{{count}} duplicati saltati",
|
||||
"duplicateCountHint": "Questi messaggi sono già archiviati",
|
||||
"failed": "Importazione fallita",
|
||||
"failedCount": "{{count}} falliti",
|
||||
"failedDetails": "Elementi falliti",
|
||||
"fileCount": "{{count}} file",
|
||||
"folder": "Cartella",
|
||||
"folderMethod": "2. Scegli il metodo della cartella",
|
||||
"folderMethod": "3. Scegli il metodo della cartella",
|
||||
"folderMethodDesc": "Come determinare la cartella di posta di destinazione?",
|
||||
"folderStructure": "2. Struttura delle cartelle",
|
||||
"folderStructure": "3. Struttura delle cartelle",
|
||||
"importHistory": "Cronologia importazioni",
|
||||
"limits": "Max: EML 100 MB · MBOX {{maxMbox}} MB · PST {{maxPst}} MB. File più grandi → CLI.",
|
||||
"modeCustom": "Inserisci un nome cartella personalizzato",
|
||||
@@ -680,6 +759,7 @@
|
||||
"modeHeaderDesc": "Legge X-Gmail-Labels / X-Bichon-Metadata dal file. Alternativa: nome del file.",
|
||||
"noAccountFound": "Nessun account trovato.",
|
||||
"noFileYet": "Nessun file selezionato",
|
||||
"noFilesSelected": "Nessun file selezionato",
|
||||
"noMailboxFound": "Nessuna casella postale trouvata.",
|
||||
"noMailboxes": "Nessuna casella postale trovata in questo account.",
|
||||
"orClick": "o clicca per sfogliare",
|
||||
@@ -690,8 +770,11 @@
|
||||
"searchAccount": "Cerca account...",
|
||||
"searchMailbox": "Cerca caselle postali...",
|
||||
"selectAccount": "Seleziona un account",
|
||||
"selectAccountAndFiles": "Seleziona prima un account di destinazione e i file.",
|
||||
"selectAccountFirst": "Seleziona prima un account.",
|
||||
"selectAccountRequired": "Seleziona prima un account di destinazione.",
|
||||
"selectFileFirst": "Seleziona prima un file per determinare le opzioni disponibili.",
|
||||
"selectFilesRequired": "Seleziona i file da importare.",
|
||||
"selectMailbox": "Seleziona una casella...",
|
||||
"source": "origine",
|
||||
"startImport": "Importa",
|
||||
@@ -702,6 +785,46 @@
|
||||
"uploadingFile": "Caricamento del file",
|
||||
"willImportTo": "Sarà importato in"
|
||||
},
|
||||
"license": {
|
||||
"accounts": "Account",
|
||||
"accountsUsed": "{{used}} di {{limit}} utilizzati",
|
||||
"chooseFile": "Scegli file",
|
||||
"copied": "Copiato negli appunti",
|
||||
"copyFailed": "Copia non riuscita",
|
||||
"copyMachineId": "Copia ID macchina",
|
||||
"description": "Visualizza i dettagli della licenza corrente e aggiorna le credenziali.",
|
||||
"edition": "Edizione",
|
||||
"features": "Funzionalità",
|
||||
"forbidden": "La gestione delle licenze è disponibile solo nell'edizione Pro.",
|
||||
"licensee": "Licenziatario",
|
||||
"loadFailed": "Caricamento dello stato della licenza non riuscito.",
|
||||
"machineIdDesc": "Identificativo univoco per questo dispositivo necessario per generare una licenza offline.",
|
||||
"machineIdTitle": "ID macchina",
|
||||
"notAvailable": "Non disponibile",
|
||||
"pasteHere": "Incolla qui il contenuto della licenza...",
|
||||
"readFileFailed": "Lettura del file non riuscita",
|
||||
"status": "Stato",
|
||||
"statusDesc": "Dettagli sulla tua attivazione corrente e sulle funzionalità",
|
||||
"statusError": "Errore di licenza",
|
||||
"statusInvalid": "Firma non valida",
|
||||
"statusMachineMismatch": "ID macchina non corrispondente",
|
||||
"statusTitle": "Stato della licenza",
|
||||
"statusTrial": "Prova",
|
||||
"statusTrialExpired": "Periodo di prova scaduto",
|
||||
"statusUpdateExpired": "Aggiornamenti scaduti",
|
||||
"statusValid": "Valido",
|
||||
"title": "Gestione licenze",
|
||||
"trialDays": "Giorni di prova",
|
||||
"trialDaysRemaining": "{{days}} giorni rimanenti",
|
||||
"updatesUntil": "Aggiornamenti fino a",
|
||||
"upload": "Carica",
|
||||
"uploadDesc": "Carica il file di licenza o incolla direttamente il contenuto per applicare gli aggiornamenti.",
|
||||
"uploadFailed": "Caricamento della licenza non riuscito",
|
||||
"uploadFailedDesc": "Impossibile analizzare o convalidare il file di licenza.",
|
||||
"uploadSuccess": "Licenza caricata con successo",
|
||||
"uploadTitle": "Aggiorna licenza",
|
||||
"uploading": "Caricamento in corso..."
|
||||
},
|
||||
"mail": {
|
||||
"account": "Account",
|
||||
"attachments": "Allegati",
|
||||
@@ -791,10 +914,12 @@
|
||||
"accounts": "Account",
|
||||
"apiDocs": "Documentazione API",
|
||||
"attachment": "Allegati",
|
||||
"auditLog": "Registro di controllo",
|
||||
"auth": "Autenticazione",
|
||||
"dashboard": "Dashboard",
|
||||
"general": "Generale",
|
||||
"home": "Home",
|
||||
"license": "Licenza",
|
||||
"mailbox": "Posta in arrivo",
|
||||
"oauth2": "OAuth2",
|
||||
"other": "Altro",
|
||||
@@ -1422,6 +1547,10 @@
|
||||
}
|
||||
},
|
||||
"proxy": "Proxy",
|
||||
"proxyTest": "Testa proxy",
|
||||
"proxyTestFailed": "Connessione proxy non riuscita.",
|
||||
"proxyTestSuccess": "Connessione proxy riuscita!",
|
||||
"proxyTesting": "Test del proxy in corso...",
|
||||
"proxyUpdateOrAddFailed": "{{action}} fallita, riprova più tardi",
|
||||
"reset": "Ripristina",
|
||||
"resetRootPassword": "Ripristina Password Root",
|
||||
|
||||
@@ -98,6 +98,7 @@
|
||||
"allMailSkipped": "標準フォルダーが選択されました。「すべてのメール」は重複を避けるためにスキップされました。",
|
||||
"areYouSureYouWantTo": "本当にこのアカウントを{{action}}しますか?",
|
||||
"auth": "認証",
|
||||
"authPassword": "認証パスワード",
|
||||
"authType": "認証タイプ",
|
||||
"autoConfiguring": "自動設定中…",
|
||||
"autoDiscover": "サーバー設定の自動検出",
|
||||
@@ -116,6 +117,7 @@
|
||||
"clickSaveWhenDone": "完了したら「保存」をクリックしてください。",
|
||||
"continue": "続行",
|
||||
"createdAt": "作成日時",
|
||||
"creating": "アカウントを作成中...",
|
||||
"creationFailed": "作成に失敗しました。しばらくしてからもう一度お試しください。",
|
||||
"cronAdvanced": "高度な式",
|
||||
"cronDaily": "毎日",
|
||||
@@ -333,18 +335,26 @@
|
||||
"selectedMailboxes": "選択されたメールボックス",
|
||||
"serverConfiguration": "サーバー設定 (IMAP)",
|
||||
"settings": {
|
||||
"backToAccounts": "アカウント一覧に戻る",
|
||||
"download": "ダウンロード設定",
|
||||
"downloadDesc": "サーバーからメールを取得するタイミングと方法を設定します。",
|
||||
"filters": "フィルター",
|
||||
"filtersDesc": "アーカイブ対象のメールを制御します。フィルターを無効にすると、すべてのメールが保存されます。",
|
||||
"general": "基本情報",
|
||||
"generalDesc": "アカウントの基本情報とステータス。",
|
||||
"loading": "設定を読み込み中...",
|
||||
"newAccount": "新規アカウント",
|
||||
"performance": "パフォーマンス",
|
||||
"reset": "設定をリセット",
|
||||
"save": "設定を保存",
|
||||
"saved": "保存済み",
|
||||
"savedDesc": "アカウント設定が正常に保存されました。",
|
||||
"saving": "設定を保存中...",
|
||||
"schedule": "時間計画",
|
||||
"scope": "同期対象期間",
|
||||
"server": "サーバー設定",
|
||||
"serverDesc": "IMAP接続設定と認証。"
|
||||
"serverDesc": "IMAP接続設定と認証。",
|
||||
"settings": "アカウント設定"
|
||||
},
|
||||
"since": "以降",
|
||||
"sinceFixed": "指定した日付以降",
|
||||
@@ -470,6 +480,7 @@
|
||||
"downloading": "ダウンロード中...",
|
||||
"emailMessageNotFound": "元のメールが見つかりません。削除された可能性があります。",
|
||||
"name": "ファイル名",
|
||||
"preview": "添付ファイルをプレビュー",
|
||||
"search_input_placeholder": "添付ファイルを検索 (フレーズ検索は \" \" を使用)",
|
||||
"sender": "送信者",
|
||||
"sender_with_count": "送信者 ({{count}})",
|
||||
@@ -487,6 +498,70 @@
|
||||
"zoomIn": "拡大",
|
||||
"zoomOut": "縮小"
|
||||
},
|
||||
"audit": {
|
||||
"account": "アカウント",
|
||||
"accountPlaceholder": "アカウントを選択",
|
||||
"allAccounts": "所有のアカウント",
|
||||
"allTypes": "すべてのタイプ",
|
||||
"allUsers": "すべてのユーザー",
|
||||
"apply": "適用",
|
||||
"detail": "詳細",
|
||||
"empty": "監査イベントが見つかりません",
|
||||
"endDate": "終了日",
|
||||
"eventType": "イベントタイプ",
|
||||
"eventTypes": {
|
||||
"accessTokenCreated": "アクセストークン作成",
|
||||
"accessTokenRemoved": "アクセストークン削除",
|
||||
"accountCreated": "アカウント作成",
|
||||
"accountDownloadStarted": "アカウント同期開始",
|
||||
"accountDownloadStopped": "アカウント同期停止",
|
||||
"accountRemoved": "アカウント削除",
|
||||
"accountRoleAssigned": "アカウント権限割り当て",
|
||||
"accountUpdated": "アカウント更新",
|
||||
"attachmentDownloaded": "添付ファイルダウンロード",
|
||||
"attachmentPreviewed": "添付ファイルプレビュー",
|
||||
"attachmentTagged": "添付ファイルタグ変更",
|
||||
"emailDeleted": "メール削除",
|
||||
"emailExported": "メールエクスポート",
|
||||
"emailRestored": "メール復元",
|
||||
"emailTagged": "メールタグ変更",
|
||||
"emailViewed": "メール閲覧",
|
||||
"importPerformed": "インポート実行",
|
||||
"licenseUploaded": "ライセンスアップロード",
|
||||
"mailboxRemoved": "メールボックス削除",
|
||||
"oauth2Created": "OAuth2設定作成",
|
||||
"oauth2Removed": "OAuth2設定削除",
|
||||
"oauth2TokenStored": "OAuth2トークン保存",
|
||||
"oauth2Updated": "OAuth2設定更新",
|
||||
"proxyCreated": "プロキシ作成",
|
||||
"proxyRemoved": "プロキシ削除",
|
||||
"proxyUpdated": "プロキシ更新",
|
||||
"roleCreated": "ロール作成",
|
||||
"roleRemoved": "ロール削除",
|
||||
"roleUpdated": "ロール更新",
|
||||
"searchPerformed": "検索実行",
|
||||
"settingsChanged": "設定変更",
|
||||
"ssoLogin": "SSOログイン",
|
||||
"ssoLogout": "SSOログアウト",
|
||||
"userCreated": "ユーザー作成",
|
||||
"userLogin": "ユーザーログイン",
|
||||
"userRemoved": "ユーザー削除",
|
||||
"userUpdated": "ユーザー更新"
|
||||
},
|
||||
"forbidden": "監査ログは Pro 版でのみ利用可能です。",
|
||||
"hideDetails": "詳細を非表示",
|
||||
"ip": "IP",
|
||||
"loading": "読み込み中…",
|
||||
"noAccounts": "アカウントが見つかりません",
|
||||
"noUsers": "ユーザーが見つかりません",
|
||||
"reset": "リセット",
|
||||
"showDetails": "詳細を表示",
|
||||
"startDate": "開始日",
|
||||
"time": "日時",
|
||||
"title": "監査ログ",
|
||||
"user": "ユーザー",
|
||||
"userPlaceholder": "ユーザー名"
|
||||
},
|
||||
"auth": {
|
||||
"areYouSureYouWantToLogOut": "ログアウトしてもよろしいですか?",
|
||||
"invalidPassword": "パスワードが無効です。もう一度お試しください。",
|
||||
@@ -497,6 +572,7 @@
|
||||
"sessionExpired": "セッションの有効期限が切れました!",
|
||||
"sessionExpiredDesc": "操作がないためセッションが終了しました。継続するには再度ログインしてください。",
|
||||
"somethingWentWrong": "問題が発生しました",
|
||||
"ssoLogin": "SSO ログイン",
|
||||
"username": "ユーザー名",
|
||||
"welcome": "Bichon へようこそ",
|
||||
"youWillNeedToLogInAgain": "アカウントにアクセスするには、再度ログインする必要があります。"
|
||||
@@ -657,19 +733,22 @@
|
||||
},
|
||||
"import": {
|
||||
"account": "アカウント",
|
||||
"chooseFiles": "3. ファイルを選択",
|
||||
"chooseFiles": "2. ファイルを選択",
|
||||
"completed": "インポート完了",
|
||||
"description": "NoSyncローカルアカウントにメールファイルをインポートします。大容量ファイルはCLIを使用してください。",
|
||||
"detectedFolder": "放出演出",
|
||||
"detectedFrom": "検出元:",
|
||||
"dropHere": "ここに .eml / .mbox / .pst 文件をドロップ",
|
||||
"duplicateCount": "{{count}} 件の重複をスキップ",
|
||||
"duplicateCountHint": "これらのメッセージは既にアーカイブ済みです",
|
||||
"failed": "インポート失敗",
|
||||
"failedCount": "{{count}} 件の失敗",
|
||||
"failedDetails": "失敗したアイテム",
|
||||
"fileCount": "{{count}} 件のファイル",
|
||||
"folder": "フォルダ",
|
||||
"folderMethod": "2. フォルダ指定方法の選択",
|
||||
"folderMethod": "3. フォルダ指定方法の選択",
|
||||
"folderMethodDesc": "インポート先のフォルダをどのように決定しますか?",
|
||||
"folderStructure": "2. フォルダ構造",
|
||||
"folderStructure": "3. フォルダ構造",
|
||||
"importHistory": "インポート履歴",
|
||||
"limits": "上限: EML 100 MB · MBOX {{maxMbox}} MB · PST {{maxPst}} MB。これ以上のサイズは → CLIへ。",
|
||||
"modeCustom": "カスタムフォルダ名を入力",
|
||||
@@ -680,6 +759,7 @@
|
||||
"modeHeaderDesc": "ファイルから X-Gmail-Labels / X-Bichon-Metadata を読み取ります。ない場合はファイル名を使用します。",
|
||||
"noAccountFound": "アカウントが見つかりません。",
|
||||
"noFileYet": "ファイルが選択されていません",
|
||||
"noFilesSelected": "ファイルが選択されていません",
|
||||
"noMailboxFound": "メールボックスが見つかりません。",
|
||||
"noMailboxes": "このアカウントにメールボックスが見つかりません。",
|
||||
"orClick": "またはクリックしてファイルを選択",
|
||||
@@ -690,8 +770,11 @@
|
||||
"searchAccount": "アカウントを検索...",
|
||||
"searchMailbox": "メールボックスを検索...",
|
||||
"selectAccount": "アカウントを選択",
|
||||
"selectAccountAndFiles": "最初に対象のアカウントとファイルを選択してください。",
|
||||
"selectAccountFirst": "最初にアカウントを選択してください。",
|
||||
"selectAccountRequired": "最初に対象のアカウントを選択してください。",
|
||||
"selectFileFirst": "利用可能なオプションを確認するには、最初にファイルを選択してください。",
|
||||
"selectFilesRequired": "インポートするファイルを選択してください。",
|
||||
"selectMailbox": "メールボックスを選択...",
|
||||
"source": "ソース",
|
||||
"startImport": "インポート",
|
||||
@@ -702,6 +785,46 @@
|
||||
"uploadingFile": "ファイルをアップロード中",
|
||||
"willImportTo": "インポート先:"
|
||||
},
|
||||
"license": {
|
||||
"accounts": "アカウント",
|
||||
"accountsUsed": "{{limit}} 中 {{used}} 使用中",
|
||||
"chooseFile": "ファイルを選択",
|
||||
"copied": "クリップボードにコピーしました",
|
||||
"copyFailed": "コピーに失敗しました",
|
||||
"copyMachineId": "マシン ID をコピー",
|
||||
"description": "現在のライセンスの詳細を表示し、資格情報を更新します。",
|
||||
"edition": "エディション",
|
||||
"features": "機能",
|
||||
"forbidden": "ライセンス管理は Pro 版でのみ利用可能です。",
|
||||
"licensee": "ライセンシー",
|
||||
"loadFailed": "ライセンスステータスの読み込みに失敗しました。",
|
||||
"machineIdDesc": "オフラインライセンスを生成するために必要な、このデバイスの固有の識別子です。",
|
||||
"machineIdTitle": "マシン ID",
|
||||
"notAvailable": "N/A",
|
||||
"pasteHere": "ここにライセンス内容を貼り付けてください...",
|
||||
"readFileFailed": "ファイルの読み込みに失敗しました",
|
||||
"status": "ステータス",
|
||||
"statusDesc": "現在の有効化および機能の詳細",
|
||||
"statusError": "ライセンスエラー",
|
||||
"statusInvalid": "無効な署名",
|
||||
"statusMachineMismatch": "マシン ID が一致しません",
|
||||
"statusTitle": "ライセンスステータス",
|
||||
"statusTrial": "試用中",
|
||||
"statusTrialExpired": "試用期限切れ",
|
||||
"statusUpdateExpired": "更新期限切れ",
|
||||
"statusValid": "有効",
|
||||
"title": "ライセンス管理",
|
||||
"trialDays": "試用日数",
|
||||
"trialDaysRemaining": "残り {{days}} 日",
|
||||
"updatesUntil": "更新期限",
|
||||
"upload": "アップロード",
|
||||
"uploadDesc": "ライセンスファイルをアップロードするか、コンテンツを直接貼り付けて更新を適用します。",
|
||||
"uploadFailed": "ライセンスのアップロードに失敗しました",
|
||||
"uploadFailedDesc": "ライセンスファイルを解析または検証できませんでした。",
|
||||
"uploadSuccess": "ライセンスが正常にアップロードされました",
|
||||
"uploadTitle": "ライセンスを更新",
|
||||
"uploading": "アップロード中..."
|
||||
},
|
||||
"mail": {
|
||||
"account": "アカウント",
|
||||
"attachments": "添付ファイル",
|
||||
@@ -791,10 +914,12 @@
|
||||
"accounts": "アカウント",
|
||||
"apiDocs": "APIドキュメント",
|
||||
"attachment": "添付ファイル",
|
||||
"auditLog": "監査ログ",
|
||||
"auth": "認証",
|
||||
"dashboard": "ダッシュボード",
|
||||
"general": "一般",
|
||||
"home": "ホーム",
|
||||
"license": "ライセンス",
|
||||
"mailbox": "メールボックス",
|
||||
"oauth2": "OAuth2",
|
||||
"other": "その他",
|
||||
@@ -1422,6 +1547,10 @@
|
||||
}
|
||||
},
|
||||
"proxy": "プロキシ",
|
||||
"proxyTest": "プロキシをテスト",
|
||||
"proxyTestFailed": "プロキシ接続に失敗しました。",
|
||||
"proxyTestSuccess": "プロキシ接続に成功しました!",
|
||||
"proxyTesting": "プロキシをテスト中...",
|
||||
"proxyUpdateOrAddFailed": "{{action}}に失敗しました。しばらくしてからもう一度お試しください",
|
||||
"reset": "リセット",
|
||||
"resetRootPassword": "ルートパスワードをリセット",
|
||||
|
||||
@@ -98,6 +98,7 @@
|
||||
"allMailSkipped": "기본 폴더가 선택되었습니다. 중복을 방지하기 위해 '모든 메일'은 건너뛰었습니다.",
|
||||
"areYouSureYouWantTo": "이 계정을 정말 {{action}}하시겠습니까?",
|
||||
"auth": "인증",
|
||||
"authPassword": "인증 비밀번호",
|
||||
"authType": "인증 유형",
|
||||
"autoConfiguring": "자동 설정 중…",
|
||||
"autoDiscover": "서버 설정 자동 검색",
|
||||
@@ -116,6 +117,7 @@
|
||||
"clickSaveWhenDone": "완료되면 '저장'을 클릭하십시오.",
|
||||
"continue": "계속",
|
||||
"createdAt": "생성일",
|
||||
"creating": "계정 생성 중...",
|
||||
"creationFailed": "생성에 실패했습니다. 나중에 다시 시도하십시오.",
|
||||
"cronAdvanced": "고급 표현식",
|
||||
"cronDaily": "매일",
|
||||
@@ -333,18 +335,26 @@
|
||||
"selectedMailboxes": "선택된 메일함",
|
||||
"serverConfiguration": "서버 구성 (IMAP)",
|
||||
"settings": {
|
||||
"backToAccounts": "계정 목록으로 돌아가기",
|
||||
"download": "다운로드 설정",
|
||||
"downloadDesc": "서버에서 이메일을 가져오는 시기와 방법을 설정합니다.",
|
||||
"filters": "필터",
|
||||
"filtersDesc": "보관할 이메일을 제어합니다. 필터링을 비활성화하면 모든 이메일이 저장됩니다.",
|
||||
"general": "기본 정보",
|
||||
"generalDesc": "기본 계정 정보 및 상태입니다.",
|
||||
"loading": "설정 불러오는 중...",
|
||||
"newAccount": "새 계정",
|
||||
"performance": "성능",
|
||||
"reset": "설정 초기화",
|
||||
"save": "설정 저장",
|
||||
"saved": "저장됨",
|
||||
"savedDesc": "계정 설정이 성공적으로 저장되었습니다.",
|
||||
"saving": "설정 저장 중...",
|
||||
"schedule": "시간 계획",
|
||||
"scope": "동기화 범위",
|
||||
"server": "서버 설정",
|
||||
"serverDesc": "IMAP 연결 설정 및 인증입니다."
|
||||
"serverDesc": "IMAP 연결 설정 및 인증입니다.",
|
||||
"settings": "계정 설정"
|
||||
},
|
||||
"since": "이후",
|
||||
"sinceFixed": "특정 날짜 이후",
|
||||
@@ -470,6 +480,7 @@
|
||||
"downloading": "다운로드 중...",
|
||||
"emailMessageNotFound": "원본 메일을 찾을 수 없습니다. 삭제되었을 수 있습니다.",
|
||||
"name": "파일 이름",
|
||||
"preview": "첨부 파일 미리보기",
|
||||
"search_input_placeholder": "첨부 파일 검색 (구문 검색은 \" \" 사용)",
|
||||
"sender": "보낸 사람",
|
||||
"sender_with_count": "보낸 사람 ({{count}})",
|
||||
@@ -487,6 +498,70 @@
|
||||
"zoomIn": "확대",
|
||||
"zoomOut": "축소"
|
||||
},
|
||||
"audit": {
|
||||
"account": "계정",
|
||||
"accountPlaceholder": "계정 선택",
|
||||
"allAccounts": "모든 계정",
|
||||
"allTypes": "모든 유형",
|
||||
"allUsers": "모든 사용자",
|
||||
"apply": "적용",
|
||||
"detail": "상세",
|
||||
"empty": "감사 이벤트를 찾을 수 없습니다",
|
||||
"endDate": "종료일",
|
||||
"eventType": "이벤트 유형",
|
||||
"eventTypes": {
|
||||
"accessTokenCreated": "액세스 토큰 생성",
|
||||
"accessTokenRemoved": "액세스 토큰 삭제",
|
||||
"accountCreated": "계정 생성",
|
||||
"accountDownloadStarted": "계정 동기화 시작",
|
||||
"accountDownloadStopped": "계정 동기화 중지",
|
||||
"accountRemoved": "계정 삭제",
|
||||
"accountRoleAssigned": "계정 권한 할당",
|
||||
"accountUpdated": "계정 수정",
|
||||
"attachmentDownloaded": "첨부파일 다운로드",
|
||||
"attachmentPreviewed": "첨부파일 미리보기",
|
||||
"attachmentTagged": "첨부파일 태그 변경",
|
||||
"emailDeleted": "메일 삭제",
|
||||
"emailExported": "메일 내보내기",
|
||||
"emailRestored": "메일 복원",
|
||||
"emailTagged": "메일 태그 변경",
|
||||
"emailViewed": "메일 조회",
|
||||
"importPerformed": "가져오기 실행",
|
||||
"licenseUploaded": "라이선스 업로드",
|
||||
"mailboxRemoved": "메일함 삭제",
|
||||
"oauth2Created": "OAuth2 설정 생성",
|
||||
"oauth2Removed": "OAuth2 설정 삭제",
|
||||
"oauth2TokenStored": "OAuth2 토큰 저장",
|
||||
"oauth2Updated": "OAuth2 설정 수정",
|
||||
"proxyCreated": "프록시 생성",
|
||||
"proxyRemoved": "프록시 삭제",
|
||||
"proxyUpdated": "프록시 수정",
|
||||
"roleCreated": "역할 생성",
|
||||
"roleRemoved": "역할 삭제",
|
||||
"roleUpdated": "역할 수정",
|
||||
"searchPerformed": "검색 실행",
|
||||
"settingsChanged": "설정 변경",
|
||||
"ssoLogin": "SSO 로그인",
|
||||
"ssoLogout": "SSO 로그아웃",
|
||||
"userCreated": "사용자 생성",
|
||||
"userLogin": "사용자 로그인",
|
||||
"userRemoved": "사용자 삭제",
|
||||
"userUpdated": "사용자 수정"
|
||||
},
|
||||
"forbidden": "감사 로그는 Pro 버전에만 제공됩니다.",
|
||||
"hideDetails": "상세 숨기기",
|
||||
"ip": "IP",
|
||||
"loading": "로딩 중…",
|
||||
"noAccounts": "계정을 찾을 수 없습니다",
|
||||
"noUsers": "사용자를 찾을 수 없습니다",
|
||||
"reset": "초기화",
|
||||
"showDetails": "상세 보기",
|
||||
"startDate": "시작일",
|
||||
"time": "시간",
|
||||
"title": "감사 로그",
|
||||
"user": "사용자",
|
||||
"userPlaceholder": "사용자 이름"
|
||||
},
|
||||
"auth": {
|
||||
"areYouSureYouWantToLogOut": "정말로 로그아웃하시겠습니까?",
|
||||
"invalidPassword": "비밀번호가 유효하지 않습니다. 다시 시도해 주세요.",
|
||||
@@ -497,6 +572,7 @@
|
||||
"sessionExpired": "세션이 만료되었습니다!",
|
||||
"sessionExpiredDesc": "비활성화로 인해 세션이 종료되었습니다. 계속하려면 다시 로그인하십시오.",
|
||||
"somethingWentWrong": "문제가 발생했습니다",
|
||||
"ssoLogin": "SSO 로그인",
|
||||
"username": "사용자 이름",
|
||||
"welcome": "Bichon에 오신 것을 환영합니다",
|
||||
"youWillNeedToLogInAgain": "계정에 접근하려면 다시 로그인해야 합니다."
|
||||
@@ -657,19 +733,22 @@
|
||||
},
|
||||
"import": {
|
||||
"account": "계정",
|
||||
"chooseFiles": "3. 파일 선택",
|
||||
"chooseFiles": "2. 파일 선택",
|
||||
"completed": "가져오기 완료",
|
||||
"description": "로컬 계정(NoSync)으로 이메일 파일을 가져옵니다. 대용량 파일은 CLI를 사용하세요.",
|
||||
"detectedFolder": "감지됨",
|
||||
"detectedFrom": "감지 대상:",
|
||||
"dropHere": "여기에 .eml / .mbox / .pst 파일 끌어놓기",
|
||||
"duplicateCount": "{{count}}개 중복 건너뜀",
|
||||
"duplicateCountHint": "이미 아카이브된 메시지입니다",
|
||||
"failed": "가져오기 실패",
|
||||
"failedCount": "{{count}}개 실패",
|
||||
"failedDetails": "실패한 항목",
|
||||
"fileCount": "{{count}}개 파일",
|
||||
"folder": "폴더",
|
||||
"folderMethod": "2. 폴더 지정 방식 선택",
|
||||
"folderMethod": "3. 폴더 지정 방식 선택",
|
||||
"folderMethodDesc": "가져올 메일 폴더를 어떻게 결정하시겠습니까?",
|
||||
"folderStructure": "2. 폴더 구조",
|
||||
"folderStructure": "3. 폴더 구조",
|
||||
"importHistory": "가져오기 기록",
|
||||
"limits": "제한: EML 100 MB · MBOX {{maxMbox}} MB · PST {{maxPst}} MB. 더 큰 파일은 → CLI 사용.",
|
||||
"modeCustom": "사용자 지정 폴더 이름 입력",
|
||||
@@ -680,6 +759,7 @@
|
||||
"modeHeaderDesc": "파일에서 X-Gmail-Labels / X-Bichon-Metadata를 읽습니다. 없을 경우 파일명을 사용합니다.",
|
||||
"noAccountFound": "계정을 찾을 수 없습니다.",
|
||||
"noFileYet": "선택된 파일 없음",
|
||||
"noFilesSelected": "선택된 파일이 없습니다",
|
||||
"noMailboxFound": "편지함을 찾을 수 없습니다.",
|
||||
"noMailboxes": "이 계정에서 편지함을 찾을 수 없습니다.",
|
||||
"orClick": "또는 클릭하여 찾아보기",
|
||||
@@ -690,8 +770,11 @@
|
||||
"searchAccount": "계정 검색...",
|
||||
"searchMailbox": "편지함 검색...",
|
||||
"selectAccount": "계정 선택",
|
||||
"selectAccountAndFiles": "먼저 대상 계정과 파일을 선택해 주세요.",
|
||||
"selectAccountFirst": "계정을 먼저 선택해 주세요.",
|
||||
"selectAccountRequired": "먼저 대상 계정을 선택해 주세요.",
|
||||
"selectFileFirst": "사용 가능한 옵션을 확인하려면 먼저 파일을 선택해 주세요.",
|
||||
"selectFilesRequired": "가져올 파일을 선택해 주세요.",
|
||||
"selectMailbox": "편지함 선택...",
|
||||
"source": "소스",
|
||||
"startImport": "가져오기",
|
||||
@@ -702,6 +785,46 @@
|
||||
"uploadingFile": "파일 업로드 중",
|
||||
"willImportTo": "가져올 위치:"
|
||||
},
|
||||
"license": {
|
||||
"accounts": "계정",
|
||||
"accountsUsed": "{{limit}}개 중 {{used}}개 사용됨",
|
||||
"chooseFile": "파일 선택",
|
||||
"copied": "클립보드에 복사되었습니다",
|
||||
"copyFailed": "복사 실패",
|
||||
"copyMachineId": "머신 ID 복사",
|
||||
"description": "현재 라이선스 세부 정보를 확인하고 자격 증명을 업데이트하세요.",
|
||||
"edition": "에디션",
|
||||
"features": "기능",
|
||||
"forbidden": "라이선스 관리는 Pro 버전에서만 사용할 수 있습니다.",
|
||||
"licensee": "라이선스 사용자",
|
||||
"loadFailed": "라이선스 상태를 불러오지 못했습니다.",
|
||||
"machineIdDesc": "오프라인 라이선스를 생성하는 데 필요한 이 기기의 고유 식별자입니다.",
|
||||
"machineIdTitle": "머신 ID",
|
||||
"notAvailable": "해당 없음",
|
||||
"pasteHere": "여기에 라이선스 내용을 붙여넣으세요...",
|
||||
"readFileFailed": "파일 읽기 실패",
|
||||
"status": "상태",
|
||||
"statusDesc": "현재 활성화 및 기능 세부 정보",
|
||||
"statusError": "라이선스 오류",
|
||||
"statusInvalid": "유효하지 않은 서명",
|
||||
"statusMachineMismatch": "머신 ID 불일치",
|
||||
"statusTitle": "라이선스 상태",
|
||||
"statusTrial": "체험판",
|
||||
"statusTrialExpired": "체험 기간 만료",
|
||||
"statusUpdateExpired": "업데이트 기간 만료",
|
||||
"statusValid": "유효함",
|
||||
"title": "라이선스 관리",
|
||||
"trialDays": "체험 일수",
|
||||
"trialDaysRemaining": "{{days}}일 남음",
|
||||
"updatesUntil": "업데이트 기한",
|
||||
"upload": "업로드",
|
||||
"uploadDesc": "라이선스 파일을 업로드하거나 내용을 직접 붙여넣어 업데이트를 적용하세요.",
|
||||
"uploadFailed": "라이선스 업로드 실패",
|
||||
"uploadFailedDesc": "라이선스 파일을 구문 분석하거나 검증할 수 없습니다.",
|
||||
"uploadSuccess": "라이선스가 성공적으로 업로드되었습니다",
|
||||
"uploadTitle": "라이선스 업데이트",
|
||||
"uploading": "업로드 중..."
|
||||
},
|
||||
"mail": {
|
||||
"account": "계정",
|
||||
"attachments": "첨부 파일",
|
||||
@@ -791,10 +914,12 @@
|
||||
"accounts": "계정",
|
||||
"apiDocs": "API 문서",
|
||||
"attachment": "첨부파일",
|
||||
"auditLog": "감사 로그",
|
||||
"auth": "인증",
|
||||
"dashboard": "대시보드",
|
||||
"general": "일반",
|
||||
"home": "홈",
|
||||
"license": "라이선스",
|
||||
"mailbox": "받은 편지함",
|
||||
"oauth2": "OAuth2",
|
||||
"other": "기타",
|
||||
@@ -1422,6 +1547,10 @@
|
||||
}
|
||||
},
|
||||
"proxy": "프록시",
|
||||
"proxyTest": "프록시 테스트",
|
||||
"proxyTestFailed": "프록시 연결에 실패했습니다.",
|
||||
"proxyTestSuccess": "프록시 연결에 성공했습니다!",
|
||||
"proxyTesting": "프록시 테스트 중...",
|
||||
"proxyUpdateOrAddFailed": "{{action}}에 실패했습니다. 나중에 다시 시도하십시오",
|
||||
"reset": "초기화",
|
||||
"resetRootPassword": "루트 비밀번호 초기화",
|
||||
|
||||
@@ -98,6 +98,7 @@
|
||||
"allMailSkipped": "Standaardmappen geselecteerd. 'Alle Mail' is overgeslagen om duplicaten te voorkomen.",
|
||||
"areYouSureYouWantTo": "Weet je zeker dat je dit account wilt {{action}}?",
|
||||
"auth": "Authenticatie",
|
||||
"authPassword": "Authenticatiewachtwoord",
|
||||
"authType": "authenticatie_type",
|
||||
"autoConfiguring": "Automatisch configureren…",
|
||||
"autoDiscover": "Serverinstellingen automatisch detecteren",
|
||||
@@ -116,6 +117,7 @@
|
||||
"clickSaveWhenDone": "Klik op opslaan als u klaar bent.",
|
||||
"continue": "Doorgaan",
|
||||
"createdAt": "Aangemaakt Op",
|
||||
"creating": "Account aanmaken...",
|
||||
"creationFailed": "Aanmaken mislukt, probeer het later opnieuw",
|
||||
"cronAdvanced": "Geavanceerde expressie",
|
||||
"cronDaily": "Dagelijks",
|
||||
@@ -333,18 +335,26 @@
|
||||
"selectedMailboxes": "Geselecteerde mailboxen",
|
||||
"serverConfiguration": "Serverconfiguratie (IMAP)",
|
||||
"settings": {
|
||||
"backToAccounts": "Terug naar accounts",
|
||||
"download": "Downloaden",
|
||||
"downloadDesc": "Configureren wanneer en hoe e-mails van de server worden opgehaald.",
|
||||
"filters": "Filters",
|
||||
"filtersDesc": "Bepaal welke e-mails worden gearchiveerd. Als filtering is uitgeschakeld, worden alle e-mails opgeslagen.",
|
||||
"general": "Algemeen",
|
||||
"generalDesc": "Basisaccountinformatie en status.",
|
||||
"loading": "Instellingen laden...",
|
||||
"newAccount": "Nieuw account",
|
||||
"performance": "Prestaties",
|
||||
"reset": "Instellingen herstellen",
|
||||
"save": "Instellingen opslaan",
|
||||
"saved": "Opgeslagen",
|
||||
"savedDesc": "Accountinstellingen succesvol opgeslagen.",
|
||||
"saving": "Instellingen opslaan...",
|
||||
"schedule": "Tijdschema",
|
||||
"scope": "Bereik",
|
||||
"server": "Server",
|
||||
"serverDesc": "IMAP-verbindinginstellingen en authenticatie."
|
||||
"serverDesc": "IMAP-verbindinginstellingen en authenticatie.",
|
||||
"settings": "Accountinstellingen"
|
||||
},
|
||||
"since": "sinds",
|
||||
"sinceFixed": "Sinds een specifieke datum",
|
||||
@@ -470,6 +480,7 @@
|
||||
"downloading": "Downloaden...",
|
||||
"emailMessageNotFound": "Kan de originele e-mail niet vinden. Deze is mogelijk verwijderd.",
|
||||
"name": "Bestandsnaam",
|
||||
"preview": "Bijlage bekijken",
|
||||
"search_input_placeholder": "Zoek bijlagen (gebruik \" \" voor woordgroepen)",
|
||||
"sender": "Afzender",
|
||||
"sender_with_count": "Afzender ({{count}})",
|
||||
@@ -487,6 +498,70 @@
|
||||
"zoomIn": "Inzoomen",
|
||||
"zoomOut": "Uitzoomen"
|
||||
},
|
||||
"audit": {
|
||||
"account": "Account",
|
||||
"accountPlaceholder": "Selecteer account",
|
||||
"allAccounts": "Alle accounts",
|
||||
"allTypes": "Alle typen",
|
||||
"allUsers": "Alle gebruikers",
|
||||
"apply": "Toepassen",
|
||||
"detail": "Detail",
|
||||
"empty": "Geen auditgebeurtenissen gevonden",
|
||||
"endDate": "Einddatum",
|
||||
"eventType": "Gebeurtenistype",
|
||||
"eventTypes": {
|
||||
"accessTokenCreated": "Toegangstoken aangemaakt",
|
||||
"accessTokenRemoved": "Toegangstoken verwijderd",
|
||||
"accountCreated": "Account aangemaakt",
|
||||
"accountDownloadStarted": "Accountsynch. gestart",
|
||||
"accountDownloadStopped": "Accountsynch. gestopt",
|
||||
"accountRemoved": "Account verwijderd",
|
||||
"accountRoleAssigned": "Accounttoegang toegewezen",
|
||||
"accountUpdated": "Account bijgewerkt",
|
||||
"attachmentDownloaded": "Bijlage gedownload",
|
||||
"attachmentPreviewed": "Bijlage bekeken",
|
||||
"attachmentTagged": "Bijlagentags gewijzigd",
|
||||
"emailDeleted": "E-mail verwijderd",
|
||||
"emailExported": "E-mail geëxporteerd",
|
||||
"emailRestored": "E-mail hersteld",
|
||||
"emailTagged": "E-mailtags gewijzigd",
|
||||
"emailViewed": "E-mail bekeken",
|
||||
"importPerformed": "Import uitgevoerd",
|
||||
"licenseUploaded": "Licentie geüpload",
|
||||
"mailboxRemoved": "Postvak verwijderd",
|
||||
"oauth2Created": "OAuth2-config. aangemaakt",
|
||||
"oauth2Removed": "OAuth2-config. verwijderd",
|
||||
"oauth2TokenStored": "OAuth2-token opgeslagen",
|
||||
"oauth2Updated": "OAuth2-config. bijgewerkt",
|
||||
"proxyCreated": "Proxy aangemaakt",
|
||||
"proxyRemoved": "Proxy verwijderd",
|
||||
"proxyUpdated": "Proxy bijgewerkt",
|
||||
"roleCreated": "Rol aangemaakt",
|
||||
"roleRemoved": "Rol verwijderd",
|
||||
"roleUpdated": "Rol bijgewerkt",
|
||||
"searchPerformed": "Zoekopdracht uitgevoerd",
|
||||
"settingsChanged": "Instellingen gewijzigd",
|
||||
"ssoLogin": "SSO-inlog",
|
||||
"ssoLogout": "SSO-uitlog",
|
||||
"userCreated": "Gebruiker aangemaakt",
|
||||
"userLogin": "Gebruikersinlog",
|
||||
"userRemoved": "Gebruiker verwijderd",
|
||||
"userUpdated": "Gebruiker bijgewerkt"
|
||||
},
|
||||
"forbidden": "Auditlogboek is alleen beschikbaar in de Pro-editie.",
|
||||
"hideDetails": "Details verbergen",
|
||||
"ip": "IP",
|
||||
"loading": "Laden...",
|
||||
"noAccounts": "Geen accounts gevonden",
|
||||
"noUsers": "Geen gebruikers gevonden",
|
||||
"reset": "Resetten",
|
||||
"showDetails": "Details tonen",
|
||||
"startDate": "Startdatum",
|
||||
"time": "Tijd",
|
||||
"title": "Auditlogboek",
|
||||
"user": "Gebruiker",
|
||||
"userPlaceholder": "gebruikersnaam"
|
||||
},
|
||||
"auth": {
|
||||
"areYouSureYouWantToLogOut": "Weet u zeker dat u wilt uitloggen?",
|
||||
"invalidPassword": "Ongeldig wachtwoord. Probeer het opnieuw.",
|
||||
@@ -497,6 +572,7 @@
|
||||
"sessionExpired": "Sessie verlopen!",
|
||||
"sessionExpiredDesc": "Uw sessie is beëindigd vanwege inactiviteit. Log opnieuw in om door te gaan.",
|
||||
"somethingWentWrong": "Er is iets fout gegaan",
|
||||
"ssoLogin": "SSO-inloggen",
|
||||
"username": "Gebruikersnaam",
|
||||
"welcome": "Welkom bij Bichon",
|
||||
"youWillNeedToLogInAgain": "U moet opnieuw inloggen om toegang te krijgen tot uw account."
|
||||
@@ -657,19 +733,22 @@
|
||||
},
|
||||
"import": {
|
||||
"account": "Account",
|
||||
"chooseFiles": "3. Kies bestanden",
|
||||
"chooseFiles": "2. Kies bestanden",
|
||||
"completed": "Import voltooid",
|
||||
"description": "Importeer e-mailbestanden in een lokaal account (NoSync). Gebruik de CLI voor grotere bestanden.",
|
||||
"detectedFolder": "Gedetecteerd",
|
||||
"detectedFrom": "Gedetecteerd uit",
|
||||
"dropHere": "Sleep .eml / .mbox / .pst bestanden hierheen",
|
||||
"duplicateCount": "{{count}} duplicaten overgeslagen",
|
||||
"duplicateCountHint": "Deze berichten zijn al gearchiveerd",
|
||||
"failed": "Import mislukt",
|
||||
"failedCount": "{{count}} mislukt",
|
||||
"failedDetails": "Mislukte items",
|
||||
"fileCount": "{{count}} bestanden",
|
||||
"folder": "Map",
|
||||
"folderMethod": "2. Kies mapmethode",
|
||||
"folderMethod": "3. Kies mapmethode",
|
||||
"folderMethodDesc": "Hoe moet de doelmap voor e-mail worden bepaald?",
|
||||
"folderStructure": "2. Mapstructuur",
|
||||
"folderStructure": "3. Mapstructuur",
|
||||
"importHistory": "Importgeschiedenis",
|
||||
"limits": "Max: EML 100 MB · MBOX {{maxMbox}} MB · PST {{maxPst}} MB. Grotere bestanden → CLI.",
|
||||
"modeCustom": "Voer een aangepaste mapnaam in",
|
||||
@@ -680,6 +759,7 @@
|
||||
"modeHeaderDesc": "Leest X-Gmail-Labels / X-Bichon-Metadata uit het bestand. Valt terug op bestandsnaam.",
|
||||
"noAccountFound": "Geen account gevonden.",
|
||||
"noFileYet": "Nog geen bestand geselecteerd",
|
||||
"noFilesSelected": "Geen bestanden geselecteerd",
|
||||
"noMailboxFound": "Geen mailbox gevonden.",
|
||||
"noMailboxes": "Geen mailboxen gevonden in dit account.",
|
||||
"orClick": "of klik om te bladeren",
|
||||
@@ -690,8 +770,11 @@
|
||||
"searchAccount": "Accounts zoeken...",
|
||||
"searchMailbox": "Mailboxen zoeken...",
|
||||
"selectAccount": "Selecteer een account",
|
||||
"selectAccountAndFiles": "Selecteer eerst een doelaccount en bestanden.",
|
||||
"selectAccountFirst": "Selecteer eerst een account.",
|
||||
"selectAccountRequired": "Selecteer eerst een doelaccount.",
|
||||
"selectFileFirst": "Selecteer eerst een bestand om de beschikbare opties te bepalen.",
|
||||
"selectFilesRequired": "Selecteer de te importeren bestanden.",
|
||||
"selectMailbox": "Selecteer een mailbox...",
|
||||
"source": "bron",
|
||||
"startImport": "Importeren",
|
||||
@@ -702,6 +785,46 @@
|
||||
"uploadingFile": "Bestand uploaden",
|
||||
"willImportTo": "Zal importeren naar"
|
||||
},
|
||||
"license": {
|
||||
"accounts": "Accounts",
|
||||
"accountsUsed": "{{used}} van {{limit}} gebruikt",
|
||||
"chooseFile": "Bestand kiezen",
|
||||
"copied": "Gekopieerd naar klembord",
|
||||
"copyFailed": "Kopiëren mislukt",
|
||||
"copyMachineId": "Kopieer machine-ID",
|
||||
"description": "Bekijk uw huidige licentiegegevens en werk inloggegevens bij.",
|
||||
"edition": "Editie",
|
||||
"features": "Functies",
|
||||
"forbidden": "Licentiebeheer is alleen beschikbaar in de Pro-editie.",
|
||||
"licensee": "Licentiehouder",
|
||||
"loadFailed": "Kan licentiestatus niet laden.",
|
||||
"machineIdDesc": "Unieke identificatie voor dit apparaat die nodig is om een offline licentie te genereren.",
|
||||
"machineIdTitle": "Machine-ID",
|
||||
"notAvailable": "Niet beschikbaar",
|
||||
"pasteHere": "Plak licentie-inhoud hier...",
|
||||
"readFileFailed": "Kan bestand niet lezen",
|
||||
"status": "Status",
|
||||
"statusDesc": "Uw huidige activatie- en functiedetails",
|
||||
"statusError": "Licentiefout",
|
||||
"statusInvalid": "Ongeldige handtekening",
|
||||
"statusMachineMismatch": "Machine-ID komt niet overeen",
|
||||
"statusTitle": "Licentiestatus",
|
||||
"statusTrial": "Proefversie",
|
||||
"statusTrialExpired": "Proefperiode verlopen",
|
||||
"statusUpdateExpired": "Updates verlopen",
|
||||
"statusValid": "Geldig",
|
||||
"title": "Licentiebeheer",
|
||||
"trialDays": "Proefdagen",
|
||||
"trialDaysRemaining": "Nog {{days}} dagen",
|
||||
"updatesUntil": "Updates tot",
|
||||
"upload": "Uploaden",
|
||||
"uploadDesc": "Upload uw licentiebestand of plak de inhoud direct om updates toe te passen.",
|
||||
"uploadFailed": "Uploaden van licentie mislukt",
|
||||
"uploadFailedDesc": "Kon het licentiebestand niet parseren of valideren.",
|
||||
"uploadSuccess": "Licentie succesvol geüpload",
|
||||
"uploadTitle": "Licentie bijwerken",
|
||||
"uploading": "Bezig met uploaden..."
|
||||
},
|
||||
"mail": {
|
||||
"account": "Account",
|
||||
"attachments": "Bijlagen",
|
||||
@@ -791,10 +914,12 @@
|
||||
"accounts": "Accounts",
|
||||
"apiDocs": "API Documentatie",
|
||||
"attachment": "Bijlagen",
|
||||
"auditLog": "Auditlogboek",
|
||||
"auth": "Authenticatie",
|
||||
"dashboard": "Dashboard",
|
||||
"general": "Algemeen",
|
||||
"home": "Startpagina",
|
||||
"license": "Licentie",
|
||||
"mailbox": "Postvak In",
|
||||
"oauth2": "OAuth2",
|
||||
"other": "Overig",
|
||||
@@ -1422,6 +1547,10 @@
|
||||
}
|
||||
},
|
||||
"proxy": "Proxy",
|
||||
"proxyTest": "Proxy testen",
|
||||
"proxyTestFailed": "Proxyverbinding mislukt.",
|
||||
"proxyTestSuccess": "Proxyverbinding geslaagd!",
|
||||
"proxyTesting": "Proxy testen...",
|
||||
"proxyUpdateOrAddFailed": "{{action}} mislukt, probeer het later opnieuw",
|
||||
"reset": "Resetten",
|
||||
"resetRootPassword": "Root Wachtwoord Resetten",
|
||||
|
||||
@@ -98,6 +98,7 @@
|
||||
"allMailSkipped": "Valgte standardmapper. 'All e-post' ble hoppet over for å unngå duplikater.",
|
||||
"areYouSureYouWantTo": "Er du sikker på at du vil {{action}} denne kontoen?",
|
||||
"auth": "Autentisering",
|
||||
"authPassword": "Autentiseringspassord",
|
||||
"authType": "autentiseringstype",
|
||||
"autoConfiguring": "Konfigurerer automatisk…",
|
||||
"autoDiscover": "Finn serverinnstillinger automatisk",
|
||||
@@ -116,6 +117,7 @@
|
||||
"clickSaveWhenDone": "Klikk lagre når du er ferdig.",
|
||||
"continue": "Fortsett",
|
||||
"createdAt": "Opprettet",
|
||||
"creating": "Oppretter konto...",
|
||||
"creationFailed": "Opprettelse mislyktes, prøv igjen senere",
|
||||
"cronAdvanced": "Avansert uttrykk",
|
||||
"cronDaily": "Daglig",
|
||||
@@ -333,18 +335,26 @@
|
||||
"selectedMailboxes": "Valgte postbokser",
|
||||
"serverConfiguration": "Serverkonfigurasjon (IMAP)",
|
||||
"settings": {
|
||||
"backToAccounts": "Tilbake til kontoer",
|
||||
"download": "Nedlasting",
|
||||
"downloadDesc": "Konfigurer når og hvordan e-poster hentes fra serveren.",
|
||||
"filters": "Filtre",
|
||||
"filtersDesc": "Styr hvilke e-poster som arkiveres. Når filtrering er deaktivert, lagres alle e-poster.",
|
||||
"general": "Generelt",
|
||||
"generalDesc": "Grunnleggende kontoinformasjon og status.",
|
||||
"loading": "Laster innstillinger...",
|
||||
"newAccount": "Ny konto",
|
||||
"performance": "Ytelse",
|
||||
"reset": "Tilbakestill innstillinger",
|
||||
"save": "Lagre innstillinger",
|
||||
"saved": "Lagret",
|
||||
"savedDesc": "Kontoinnstillinger ble lagret.",
|
||||
"saving": "Lagrer innstillinger...",
|
||||
"schedule": "Tidsplan",
|
||||
"scope": "Omfang",
|
||||
"server": "Server",
|
||||
"serverDesc": "IMAP-tilkoblingsinnstillinger og autentisering."
|
||||
"serverDesc": "IMAP-tilkoblingsinnstillinger og autentisering.",
|
||||
"settings": "Kontoinnstillinger"
|
||||
},
|
||||
"since": "siden",
|
||||
"sinceFixed": "Siden spesifikk dato",
|
||||
@@ -470,6 +480,7 @@
|
||||
"downloading": "Laster ned...",
|
||||
"emailMessageNotFound": "Fant ikke den originale e-posten. Den kan ha blitt slettet.",
|
||||
"name": "Filnavn",
|
||||
"preview": "Forhåndsvis vedlegg",
|
||||
"search_input_placeholder": "Søk etter vedlegg (bruk \" \" for frasesøk)",
|
||||
"sender": "Avsender",
|
||||
"sender_with_count": "Avsender ({{count}})",
|
||||
@@ -487,6 +498,70 @@
|
||||
"zoomIn": "Zoom inn",
|
||||
"zoomOut": "Zoom ut"
|
||||
},
|
||||
"audit": {
|
||||
"account": "Konto",
|
||||
"accountPlaceholder": "Velg konto",
|
||||
"allAccounts": "Alle konti",
|
||||
"allTypes": "Alle typer",
|
||||
"allUsers": "Alle brukere",
|
||||
"apply": "Bruk",
|
||||
"detail": "Detalj",
|
||||
"empty": "Ingen revisjonshendelser funnet",
|
||||
"endDate": "Sluttdato",
|
||||
"eventType": "Hendelsestype",
|
||||
"eventTypes": {
|
||||
"accessTokenCreated": "Tilgangstoken opprettet",
|
||||
"accessTokenRemoved": "Tilgangstoken fjernet",
|
||||
"accountCreated": "Konto opprettet",
|
||||
"accountDownloadStarted": "Kontosynkronisering startet",
|
||||
"accountDownloadStopped": "Kontosynkronisering stoppet",
|
||||
"accountRemoved": "Konto fjernet",
|
||||
"accountRoleAssigned": "Kontotilgang tildelt",
|
||||
"accountUpdated": "Konto oppdatert",
|
||||
"attachmentDownloaded": "Vedlegg lastet ned",
|
||||
"attachmentPreviewed": "Vedlegg forhåndsvist",
|
||||
"attachmentTagged": "Vedleggs-etiketter endret",
|
||||
"emailDeleted": "E-post slettet",
|
||||
"emailExported": "E-post eksportert",
|
||||
"emailRestored": "E-post gjenopprettet",
|
||||
"emailTagged": "E-post-etiketter endret",
|
||||
"emailViewed": "E-post vist",
|
||||
"importPerformed": "Import utført",
|
||||
"licenseUploaded": "Lisens lastet opp",
|
||||
"mailboxRemoved": "Postkasse fjernet",
|
||||
"oauth2Created": "OAuth2-konfigurasjon opprettet",
|
||||
"oauth2Removed": "OAuth2-konfigurasjon fjernet",
|
||||
"oauth2TokenStored": "OAuth2-token lagret",
|
||||
"oauth2Updated": "OAuth2-konfigurasjon oppdatert",
|
||||
"proxyCreated": "Proxy opprettet",
|
||||
"proxyRemoved": "Proxy fjernet",
|
||||
"proxyUpdated": "Proxy oppdatert",
|
||||
"roleCreated": "Rolle opprettet",
|
||||
"roleRemoved": "Rolle fjernet",
|
||||
"roleUpdated": "Rolle oppdatert",
|
||||
"searchPerformed": "Søk utført",
|
||||
"settingsChanged": "Innstillinger endret",
|
||||
"ssoLogin": "SSO-innlogging",
|
||||
"ssoLogout": "SSO-utlogging",
|
||||
"userCreated": "Bruker opprettet",
|
||||
"userLogin": "Brukerinnlogging",
|
||||
"userRemoved": "Bruker fjernet",
|
||||
"userUpdated": "Bruker oppdatert"
|
||||
},
|
||||
"forbidden": "Revisjonsloggen er kun tilgjengelig i Pro-utgaven.",
|
||||
"hideDetails": "Skjul detaljer",
|
||||
"ip": "IP",
|
||||
"loading": "Laster...",
|
||||
"noAccounts": "Ingen konti funnet",
|
||||
"noUsers": "Ingen brukere funnet",
|
||||
"reset": "Tilbakestill",
|
||||
"showDetails": "Vis detaljer",
|
||||
"startDate": "Startdato",
|
||||
"time": "Tid",
|
||||
"title": "Revisjonslogg",
|
||||
"user": "Bruker",
|
||||
"userPlaceholder": "brukernavn"
|
||||
},
|
||||
"auth": {
|
||||
"areYouSureYouWantToLogOut": "Er du sikker på at du vil logge ut?",
|
||||
"invalidPassword": "Ugyldig passord. Vennligst prøv igjen.",
|
||||
@@ -497,6 +572,7 @@
|
||||
"sessionExpired": "Sesjonen er utløpt!",
|
||||
"sessionExpiredDesc": "Sesjonen din er avsluttet på grunn av inaktivitet. Vennligst logg inn på nytt for å fortsette.",
|
||||
"somethingWentWrong": "Noe gikk galt",
|
||||
"ssoLogin": "SSO-innlogging",
|
||||
"username": "Brukernavn",
|
||||
"welcome": "Velkommen til Bichon",
|
||||
"youWillNeedToLogInAgain": "Du må logge inn på nytt for å få tilgang til kontoen din."
|
||||
@@ -657,19 +733,22 @@
|
||||
},
|
||||
"import": {
|
||||
"account": "Konto",
|
||||
"chooseFiles": "3. Velg filer",
|
||||
"chooseFiles": "2. Velg filer",
|
||||
"completed": "Import fullført",
|
||||
"description": "Importer e-postfiler til en lokal konto (NoSync). Bruk CLI for større filer.",
|
||||
"detectedFolder": "Registrert",
|
||||
"detectedFrom": "Registrert fra",
|
||||
"dropHere": "Slipp .eml / .mbox / .pst-filer her",
|
||||
"duplicateCount": "{{count}} duplikater hoppet over",
|
||||
"duplicateCountHint": "Disse meldingene er allerede arkivert",
|
||||
"failed": "Import mislyktes",
|
||||
"failedCount": "{{count}} feilet",
|
||||
"failedDetails": "Feilede elementer",
|
||||
"fileCount": "{{count}} filer",
|
||||
"folder": "Mappe",
|
||||
"folderMethod": "2. Velg mappemetode",
|
||||
"folderMethod": "3. Velg mappemetode",
|
||||
"folderMethodDesc": "Hvordan skal målmappen for e-post bestemmes?",
|
||||
"folderStructure": "2. Mappestruktur",
|
||||
"folderStructure": "3. Mappestruktur",
|
||||
"importHistory": "Importhistorikk",
|
||||
"limits": "Maks: EML 100 MB · MBOX {{maxMbox}} MB · PST {{maxPst}} MB. Større filer → CLI.",
|
||||
"modeCustom": "Skriv inn et egendefinert mappenavn",
|
||||
@@ -680,6 +759,7 @@
|
||||
"modeHeaderDesc": "Leser X-Gmail-Labels / X-Bichon-Metadata fra filen. Faller tillbaka til filnavn.",
|
||||
"noAccountFound": "Ingen konto fundet.",
|
||||
"noFileYet": "Ingen fil valgt ennå",
|
||||
"noFilesSelected": "Ingen filer valgt",
|
||||
"noMailboxFound": "Ingen postboks funnet.",
|
||||
"noMailboxes": "Ingen postbokser funnet på denne kontoen.",
|
||||
"orClick": "eller klikk for å bla gjennom",
|
||||
@@ -690,8 +770,11 @@
|
||||
"searchAccount": "Søk etter kontoer...",
|
||||
"searchMailbox": "Søk etter postbokser...",
|
||||
"selectAccount": "Velg en konto",
|
||||
"selectAccountAndFiles": "Velg en målkonto og filer først.",
|
||||
"selectAccountFirst": "Velg en konto først.",
|
||||
"selectAccountRequired": "Velg en målkonto først.",
|
||||
"selectFileFirst": "Velg en fil først for å se tilgjengelige alternativer.",
|
||||
"selectFilesRequired": "Velg filene som skal importeres.",
|
||||
"selectMailbox": "Velg en postboks...",
|
||||
"source": "kilde",
|
||||
"startImport": "Importer",
|
||||
@@ -702,6 +785,46 @@
|
||||
"uploadingFile": "Laster opp fil",
|
||||
"willImportTo": "Vil bli importert til"
|
||||
},
|
||||
"license": {
|
||||
"accounts": "Konti",
|
||||
"accountsUsed": "{{used}} av {{limit}} brukt",
|
||||
"chooseFile": "Velg fil",
|
||||
"copied": "Kopiert til utklippstavlen",
|
||||
"copyFailed": "Kunne ikke kopiere",
|
||||
"copyMachineId": "Kopier maskin-ID",
|
||||
"description": "Se dine gjeldende lisensdetaljer og oppdater påloggingsinformasjon.",
|
||||
"edition": "Utgave",
|
||||
"features": "Funksjoner",
|
||||
"forbidden": "Lisenshåndtering er kun tilgjengelig i Pro-utgaven.",
|
||||
"licensee": "Lisensinnehaver",
|
||||
"loadFailed": "Kunne ikke laste inn lisensstatus.",
|
||||
"machineIdDesc": "Unik identifikator for denne enheten som kreves for å generere en offline lisens.",
|
||||
"machineIdTitle": "Maskin-ID",
|
||||
"notAvailable": "Ikke tilgjengelig",
|
||||
"pasteHere": "Lim inn lisensinnhold her...",
|
||||
"readFileFailed": "Kunne ikke lese filen",
|
||||
"status": "Status",
|
||||
"statusDesc": "Dine gjeldende aktiverings- og funksjonsdetaljer",
|
||||
"statusError": "Lisensfeil",
|
||||
"statusInvalid": "Ugyldig signatur",
|
||||
"statusMachineMismatch": "Maskin-ID stemmer ikke",
|
||||
"statusTitle": "Lisensstatus",
|
||||
"statusTrial": "Prøveperiode",
|
||||
"statusTrialExpired": "Prøveperiode utløpt",
|
||||
"statusUpdateExpired": "Oppdateringsperiode utløpt",
|
||||
"statusValid": "Gyldig",
|
||||
"title": "Lisenshåndtering",
|
||||
"trialDays": "Prøvedager",
|
||||
"trialDaysRemaining": "{{days}} dager igjen",
|
||||
"updatesUntil": "Oppdateringer til",
|
||||
"upload": "Last opp",
|
||||
"uploadDesc": "Last opp lisensfilen din eller lim inn innholdet direkte for å bruke oppdateringer.",
|
||||
"uploadFailed": "Kunne ikke laste opp lisens",
|
||||
"uploadFailedDesc": "Kunne ikke analysere eller validere lisensfilen.",
|
||||
"uploadSuccess": "Lisens ble lastet opp",
|
||||
"uploadTitle": "Oppdater lisens",
|
||||
"uploading": "Laster opp..."
|
||||
},
|
||||
"mail": {
|
||||
"account": "Konto",
|
||||
"attachments": "Vedlegg",
|
||||
@@ -791,10 +914,12 @@
|
||||
"accounts": "Kontoer",
|
||||
"apiDocs": "API-dokumentasjon",
|
||||
"attachment": "Vedlegg",
|
||||
"auditLog": "Revisjonslogg",
|
||||
"auth": "Autentisering",
|
||||
"dashboard": "Oversikt",
|
||||
"general": "Generelt",
|
||||
"home": "Hjem",
|
||||
"license": "Lisens",
|
||||
"mailbox": "Postkasse",
|
||||
"oauth2": "OAuth2",
|
||||
"other": "Annet",
|
||||
@@ -1422,6 +1547,10 @@
|
||||
}
|
||||
},
|
||||
"proxy": "Proxy",
|
||||
"proxyTest": "Test proxy",
|
||||
"proxyTestFailed": "Proxy-tilkobling mislyktes.",
|
||||
"proxyTestSuccess": "Proxy-tilkobling mislyktes!",
|
||||
"proxyTesting": "Tester proxy...",
|
||||
"proxyUpdateOrAddFailed": "{{action}} mislyktes, vennligst prøv igjen senere",
|
||||
"reset": "Tilbakestill",
|
||||
"resetRootPassword": "Tilbakestill root-passord",
|
||||
|
||||
@@ -98,6 +98,7 @@
|
||||
"allMailSkipped": "Wybrane foldery standardowe. Pominięto 'Wszystkie wiadomości', aby uniknąć duplikatów.",
|
||||
"areYouSureYouWantTo": "Czy na pewno chcesz {{action}} dla tego konta?",
|
||||
"auth": "Auth",
|
||||
"authPassword": "Hasło uwierzytelniania",
|
||||
"authType": "auth_type",
|
||||
"autoConfiguring": "Automatyczna konfiguracja…",
|
||||
"autoDiscover": "Automatyczne wykrywanie ustawień serwera",
|
||||
@@ -116,6 +117,7 @@
|
||||
"clickSaveWhenDone": "Kiedy skończysz kliknij 'Utwórz'.",
|
||||
"continue": "Kontynuj",
|
||||
"createdAt": "Utworzono",
|
||||
"creating": "Tworzenie konta...",
|
||||
"creationFailed": "Bład tworzenia, spróbuj później",
|
||||
"cronAdvanced": "Zaawansowane wyrażenie",
|
||||
"cronDaily": "Codziennie",
|
||||
@@ -333,18 +335,26 @@
|
||||
"selectedMailboxes": "Wybrane skrzynki",
|
||||
"serverConfiguration": "Konfiguracja serwera (IMAP)",
|
||||
"settings": {
|
||||
"backToAccounts": "Powrót do kont",
|
||||
"download": "Pobieranie",
|
||||
"downloadDesc": "Konfiguruj, kiedy i jak wiadomości e-mail są pobierane z serwera.",
|
||||
"filters": "Filtry",
|
||||
"filtersDesc": "Kontroluj, które wiadomości są archiwizowane. Gdy filtrowanie jest wyłączone, zapisywane są wszystkie wiadomości.",
|
||||
"general": "Ogólne",
|
||||
"generalDesc": "Podstawowe informacje o koncie i jego status.",
|
||||
"loading": "Ładowanie ustawień...",
|
||||
"newAccount": "Nowe konto",
|
||||
"performance": "Wydajność",
|
||||
"reset": "Resetuj ustawienia",
|
||||
"save": "Zapisz ustawienia",
|
||||
"saved": "Zapisano",
|
||||
"savedDesc": "Ustawienia konta zostały pomyślnie zapisane.",
|
||||
"saving": "Zapisywanie ustawień...",
|
||||
"schedule": "Harmonogram",
|
||||
"scope": "Zakres",
|
||||
"server": "Serwer",
|
||||
"serverDesc": "Ustawienia połączenia IMAP i uwierzytelnianie."
|
||||
"serverDesc": "Ustawienia połączenia IMAP i uwierzytelnianie.",
|
||||
"settings": "Ustawienia konta"
|
||||
},
|
||||
"since": "od",
|
||||
"sinceFixed": "Od konkretnej daty",
|
||||
@@ -470,6 +480,7 @@
|
||||
"downloading": "Pobieranie...",
|
||||
"emailMessageNotFound": "Nie można znaleźć oryginalnej wiadomości e-mail. Mogła zostać usunięta.",
|
||||
"name": "Nazwa pliku",
|
||||
"preview": "Podgląd załącznika",
|
||||
"search_input_placeholder": "Wyszukaj załączniki (użyj \" \" do wyszukiwania fraz)",
|
||||
"sender": "Nadawca",
|
||||
"sender_with_count": "Nadawca ({{count}})",
|
||||
@@ -487,6 +498,70 @@
|
||||
"zoomIn": "Powiększ",
|
||||
"zoomOut": "Pomniejsz"
|
||||
},
|
||||
"audit": {
|
||||
"account": "Konto",
|
||||
"accountPlaceholder": "Wybierz konto",
|
||||
"allAccounts": "Wszystkie konta",
|
||||
"allTypes": "Wszystkie typy",
|
||||
"allUsers": "Wszyscy użytkownicy",
|
||||
"apply": "Zastosuj",
|
||||
"detail": "Szczegół",
|
||||
"empty": "Nie znaleziono zdarzeń audytowych",
|
||||
"endDate": "Data zakończenia",
|
||||
"eventType": "Typ zdarzenia",
|
||||
"eventTypes": {
|
||||
"accessTokenCreated": "Utworzenie tokenu dostępu",
|
||||
"accessTokenRemoved": "Usunięcie tokenu dostępu",
|
||||
"accountCreated": "Utworzenie konta",
|
||||
"accountDownloadStarted": "Rozpoczęcie synch. konta",
|
||||
"accountDownloadStopped": "Zatrzymanie synch. konta",
|
||||
"accountRemoved": "Usunięcie konta",
|
||||
"accountRoleAssigned": "Przypisanie dostępu do konta",
|
||||
"accountUpdated": "Aktualizacja konta",
|
||||
"attachmentDownloaded": "Pobranie załącznika",
|
||||
"attachmentPreviewed": "Podgląd załącznika",
|
||||
"attachmentTagged": "Zmiana tagów załącznika",
|
||||
"emailDeleted": "Usunięcie e-maila",
|
||||
"emailExported": "Eksport e-maila",
|
||||
"emailRestored": "Przywrócenie e-maila",
|
||||
"emailTagged": "Zmiana tagów e-maila",
|
||||
"emailViewed": "Wyświetlenie e-maila",
|
||||
"importPerformed": "Wykonanie importu",
|
||||
"licenseUploaded": "Przesłanie licencji",
|
||||
"mailboxRemoved": "Usunięcie skrzynki",
|
||||
"oauth2Created": "Utworzenie konfig. OAuth2",
|
||||
"oauth2Removed": "Usunięcie konfig. OAuth2",
|
||||
"oauth2TokenStored": "Zapisanie tokenu OAuth2",
|
||||
"oauth2Updated": "Aktualizacja konfig. OAuth2",
|
||||
"proxyCreated": "Utworzenie proxy",
|
||||
"proxyRemoved": "Usunięcie proxy",
|
||||
"proxyUpdated": "Aktualizacja proxy",
|
||||
"roleCreated": "Utworzenie roli",
|
||||
"roleRemoved": "Usunięcie roli",
|
||||
"roleUpdated": "Aktualizacja roli",
|
||||
"searchPerformed": "Wykonanie wyszukiwania",
|
||||
"settingsChanged": "Zmiana ustawień",
|
||||
"ssoLogin": "Logowanie SSO",
|
||||
"ssoLogout": "Wylogowanie SSO",
|
||||
"userCreated": "Utworzenie użytkownika",
|
||||
"userLogin": "Logowanie użytkownika",
|
||||
"userRemoved": "Usunięcie użytkownika",
|
||||
"userUpdated": "Aktualizacja użytkownika"
|
||||
},
|
||||
"forbidden": "Dziennik zdarzeń jest dostępny tylko w wersji Pro.",
|
||||
"hideDetails": "Ukryj szczegóły",
|
||||
"ip": "IP",
|
||||
"loading": "Ładowanie...",
|
||||
"noAccounts": "Nie znaleziono kont",
|
||||
"noUsers": "Nie znaleziono użytkowników",
|
||||
"reset": "Resetuj",
|
||||
"showDetails": "Pokaż szczegóły",
|
||||
"startDate": "Data rozpoczęcia",
|
||||
"time": "Czas",
|
||||
"title": "Dziennik zdarzeń",
|
||||
"user": "Użytkownik",
|
||||
"userPlaceholder": "nazwa użytkownika"
|
||||
},
|
||||
"auth": {
|
||||
"areYouSureYouWantToLogOut": "Czy na pewno chcesz się wylogować?",
|
||||
"invalidPassword": "Nieprawidłowe hasło, spróbuj ponownie",
|
||||
@@ -497,6 +572,7 @@
|
||||
"sessionExpired": "Sesja wygasła",
|
||||
"sessionExpiredDesc": "Twoja sesja wygasła z powodu bezczynności. Zaloguj się ponownie",
|
||||
"somethingWentWrong": "Coś poszło nie tak",
|
||||
"ssoLogin": "Logowanie SSO",
|
||||
"username": "Nazwa",
|
||||
"welcome": "Witaj w Bichon",
|
||||
"youWillNeedToLogInAgain": "Musisz zalogować się ponownie, aby ponownie móc korzystać."
|
||||
@@ -657,19 +733,22 @@
|
||||
},
|
||||
"import": {
|
||||
"account": "Konto",
|
||||
"chooseFiles": "3. Wybierz pliki",
|
||||
"chooseFiles": "2. Wybierz pliki",
|
||||
"completed": "Import zakończony",
|
||||
"description": "Importuj pliki e-mail do konta lokalnego (NoSync). W przypadku większych plików użyj CLI.",
|
||||
"detectedFolder": "Wykryto",
|
||||
"detectedFrom": "Wykryto z",
|
||||
"dropHere": "Upuść pliki .eml / .mbox / .pst tutaj",
|
||||
"duplicateCount": "Pominięto duplikatów: {{count}}",
|
||||
"duplicateCountHint": "Te wiadomości są już zarchiwizowane",
|
||||
"failed": "Import nie powiódł się",
|
||||
"failedCount": "Niepowodzenie: {{count}}",
|
||||
"failedDetails": "Nieudane elementy",
|
||||
"fileCount": "Liczba plików: {{count}}",
|
||||
"folder": "Folder",
|
||||
"folderMethod": "2. Wybierz metodę folderu",
|
||||
"folderMethod": "3. Wybierz metodę folderu",
|
||||
"folderMethodDesc": "Jak ma zostać określony docelowy folder poczty?",
|
||||
"folderStructure": "2. Struktura folderów",
|
||||
"folderStructure": "3. Struktura folderów",
|
||||
"importHistory": "Historia importu",
|
||||
"limits": "Maks: EML 100 MB · MBOX {{maxMbox}} MB · PST {{maxPst}} MB. Większe pliki → CLI.",
|
||||
"modeCustom": "Wprowadź własną nazwę folderu",
|
||||
@@ -680,6 +759,7 @@
|
||||
"modeHeaderDesc": "Odczytaj X-Gmail-Labels / X-Bichon-Metadata z pliku. W przypadku braku użyta zostanie nazwa pliku.",
|
||||
"noAccountFound": "Nie znaleziono konta.",
|
||||
"noFileYet": "Nie wybrano jeszcze żadnego pliku",
|
||||
"noFilesSelected": "Nie wybrano żadnych plików",
|
||||
"noMailboxFound": "Nie znaleziono skrzynki pocztowej.",
|
||||
"noMailboxes": "Nie znaleziono skrzynek pocztowych na tym koncie.",
|
||||
"orClick": "lub kliknij, aby przeglądać",
|
||||
@@ -690,8 +770,11 @@
|
||||
"searchAccount": "Szukaj kont...",
|
||||
"searchMailbox": "Szukaj skrzynek...",
|
||||
"selectAccount": "Wybierz konto",
|
||||
"selectAccountAndFiles": "Najpierw wybierz konto docelowe i pliki.",
|
||||
"selectAccountFirst": "Najpierw wybierz konto.",
|
||||
"selectAccountRequired": "Najpierw wybierz konto docelowe.",
|
||||
"selectFileFirst": "Najpierw wybierz plik, aby określić dostępne opcje.",
|
||||
"selectFilesRequired": "Wybierz pliki do importu.",
|
||||
"selectMailbox": "Wybierz skrzynkę...",
|
||||
"source": "źródło",
|
||||
"startImport": "Importuj",
|
||||
@@ -702,6 +785,46 @@
|
||||
"uploadingFile": "Przesyłanie pliku",
|
||||
"willImportTo": "Zostanie zaimportowane do"
|
||||
},
|
||||
"license": {
|
||||
"accounts": "Konta",
|
||||
"accountsUsed": "Wykorzystano {{used}} z {{limit}}",
|
||||
"chooseFile": "Wybierz plik",
|
||||
"copied": "Skopiowano do schowka",
|
||||
"copyFailed": "Nie udało się skopiować",
|
||||
"copyMachineId": "Skopiuj identyfikator maszyny",
|
||||
"description": "Wyświetl szczegóły swojej bieżącej licencji i zaktualizuj poświadczenia.",
|
||||
"edition": "Wersja",
|
||||
"features": "Funkcje",
|
||||
"forbidden": "Zarządzanie licencjami jest dostępne tylko w wersji Pro.",
|
||||
"licensee": "Licencjobiorca",
|
||||
"loadFailed": "Nie udało się załadować statusu licencji.",
|
||||
"machineIdDesc": "Unikalny identyfikator tego urządzenia wymagany do wygenerowania licencji offline.",
|
||||
"machineIdTitle": "Identyfikator maszyny",
|
||||
"notAvailable": "N/D",
|
||||
"pasteHere": "Wklej treść licencji tutaj...",
|
||||
"readFileFailed": "Nie udało się odczytać pliku",
|
||||
"status": "Status",
|
||||
"statusDesc": "Szczegóły bieżącej aktywacji i funkcji",
|
||||
"statusError": "Błąd licencji",
|
||||
"statusInvalid": "Nieprawidłowa sygnatura",
|
||||
"statusMachineMismatch": "Niezgodność identyfikatora maszyny",
|
||||
"statusTitle": "Status licencji",
|
||||
"statusTrial": "Wersja próbna",
|
||||
"statusTrialExpired": "Okres próbny wygasł",
|
||||
"statusUpdateExpired": "Wygasły updates (aktualizacje)",
|
||||
"statusValid": "Ważna",
|
||||
"title": "Zarządzanie licencjami",
|
||||
"trialDays": "Dni próbne",
|
||||
"trialDaysRemaining": "Pozostało dni: {{days}}",
|
||||
"updatesUntil": "Aktualizacje do",
|
||||
"upload": "Prześlij",
|
||||
"uploadDesc": "Prześlij plik licencji lub wklej treść bezpośrednio, aby zastosować aktualizacje.",
|
||||
"uploadFailed": "Nie udało się przesłać licencji",
|
||||
"uploadFailedDesc": "Nie można przetworzyć ani zweryfikować pliku licencji.",
|
||||
"uploadSuccess": "Licencja została pomyślnie przesłana",
|
||||
"uploadTitle": "Zaktualizuj licencję",
|
||||
"uploading": "Przesyłanie..."
|
||||
},
|
||||
"mail": {
|
||||
"account": "Konto",
|
||||
"attachments": "Załączniki",
|
||||
@@ -791,10 +914,12 @@
|
||||
"accounts": "Konta",
|
||||
"apiDocs": "Dokumentacja API",
|
||||
"attachment": "Załączniki",
|
||||
"auditLog": "Dziennik audytu",
|
||||
"auth": "Auth",
|
||||
"dashboard": "Panel",
|
||||
"general": "Ogólne",
|
||||
"home": "Home",
|
||||
"license": "Licencja",
|
||||
"mailbox": "Poczta",
|
||||
"oauth2": "OAuth2",
|
||||
"other": "Pozostałe",
|
||||
@@ -1422,6 +1547,10 @@
|
||||
}
|
||||
},
|
||||
"proxy": "Proxy",
|
||||
"proxyTest": "Testuj serwer proxy",
|
||||
"proxyTestFailed": "Połączenie z serwerem proxy nie powiodło się.",
|
||||
"proxyTestSuccess": "Połączenie z serwerem proxy powiodło się!",
|
||||
"proxyTesting": "Testowanie serwera proxy...",
|
||||
"proxyUpdateOrAddFailed": "Błąd {{action}}, spróbuj później",
|
||||
"reset": "Reset",
|
||||
"resetRootPassword": "Resetuj hasło root",
|
||||
|
||||
@@ -98,6 +98,7 @@
|
||||
"allMailSkipped": "Pastas padrão selecionadas. 'Todos os Emails' foi ignorado para evitar duplicação.",
|
||||
"areYouSureYouWantTo": "Tem certeza de que deseja {{action}} esta conta?",
|
||||
"auth": "Autenticação",
|
||||
"authPassword": "Senha de autenticação",
|
||||
"authType": "Tipo de Autenticação",
|
||||
"autoConfiguring": "Configurando automaticamente…",
|
||||
"autoDiscover": "Autodetectar configurações do servidor",
|
||||
@@ -116,6 +117,7 @@
|
||||
"clickSaveWhenDone": "Clique em 'Salvar' quando terminar.",
|
||||
"continue": "Continuar",
|
||||
"createdAt": "Criado Em",
|
||||
"creating": "Criando conta...",
|
||||
"creationFailed": "Falha na criação, por favor, tente novamente mais tarde.",
|
||||
"cronAdvanced": "Expressão avançada",
|
||||
"cronDaily": "Diariamente",
|
||||
@@ -333,18 +335,26 @@
|
||||
"selectedMailboxes": "Caixas de correio selecionadas",
|
||||
"serverConfiguration": "Configuração do Servidor (IMAP)",
|
||||
"settings": {
|
||||
"backToAccounts": "Voltar para as contas",
|
||||
"download": "Download",
|
||||
"downloadDesc": "Configure quando e como os e-mails são buscados no servidor.",
|
||||
"filters": "Filtros",
|
||||
"filtersDesc": "Controle quais e-mails serão arquivados. Quando a filtragem está desativada, todos os e-mails são salvos.",
|
||||
"general": "Geral",
|
||||
"generalDesc": "Informações básicas da conta e status.",
|
||||
"loading": "Carregando configurações...",
|
||||
"newAccount": "Nova conta",
|
||||
"performance": "Desempenho",
|
||||
"reset": "Redefinir configurações",
|
||||
"save": "Salvar configurações",
|
||||
"saved": "Salvo",
|
||||
"savedDesc": "Configurações da conta salvas com sucesso.",
|
||||
"saving": "Salvando configurações...",
|
||||
"schedule": "Cronograma",
|
||||
"scope": "Escopo",
|
||||
"server": "Servidor",
|
||||
"serverDesc": "Configurações de conexão IMAP e autenticação."
|
||||
"serverDesc": "Configurações de conexão IMAP e autenticação.",
|
||||
"settings": "Configurações da conta"
|
||||
},
|
||||
"since": "Desde",
|
||||
"sinceFixed": "Desde uma data específica",
|
||||
@@ -470,6 +480,7 @@
|
||||
"downloading": "Baixando...",
|
||||
"emailMessageNotFound": "Não foi possível encontrar o e-mail original. Ele pode ter sido excluído.",
|
||||
"name": "Nome do arquivo",
|
||||
"preview": "Visualizar anexo",
|
||||
"search_input_placeholder": "Pesquisar anexos (use \" \" para pesquisa de frases)",
|
||||
"sender": "Remetente",
|
||||
"sender_with_count": "Remetente ({{count}})",
|
||||
@@ -487,6 +498,70 @@
|
||||
"zoomIn": "Mais zoom",
|
||||
"zoomOut": "Menos zoom"
|
||||
},
|
||||
"audit": {
|
||||
"account": "Conta",
|
||||
"accountPlaceholder": "Selecionar conta",
|
||||
"allAccounts": "Todas as contas",
|
||||
"allTypes": "Todos os tipos",
|
||||
"allUsers": "Todos os usuários",
|
||||
"apply": "Aplicar",
|
||||
"detail": "Detalhe",
|
||||
"empty": "Nenhum evento de auditoria encontrado",
|
||||
"endDate": "Data de término",
|
||||
"eventType": "Tipo de evento",
|
||||
"eventTypes": {
|
||||
"accessTokenCreated": "Token de acesso criado",
|
||||
"accessTokenRemoved": "Token de acesso removido",
|
||||
"accountCreated": "Conta criada",
|
||||
"accountDownloadStarted": "Sinc. de conta iniciada",
|
||||
"accountDownloadStopped": "Sinc. de conta interrompida",
|
||||
"accountRemoved": "Conta removida",
|
||||
"accountRoleAssigned": "Acesso à conta atribuído",
|
||||
"accountUpdated": "Conta atualizada",
|
||||
"attachmentDownloaded": "Anexo baixado",
|
||||
"attachmentPreviewed": "Anexo visualizado",
|
||||
"attachmentTagged": "Tags de anexo alteradas",
|
||||
"emailDeleted": "E-mail excluído",
|
||||
"emailExported": "E-mail exportado",
|
||||
"emailRestored": "E-mail restaurado",
|
||||
"emailTagged": "Tags de e-mail alteradas",
|
||||
"emailViewed": "E-mail visualizado",
|
||||
"importPerformed": "Importação realizada",
|
||||
"licenseUploaded": "Licença enviada",
|
||||
"mailboxRemoved": "Caixa de correio removida",
|
||||
"oauth2Created": "Config. OAuth2 criada",
|
||||
"oauth2Removed": "Config. OAuth2 removida",
|
||||
"oauth2TokenStored": "Token OAuth2 armazenado",
|
||||
"oauth2Updated": "Config. OAuth2 atualizada",
|
||||
"proxyCreated": "Proxy criado",
|
||||
"proxyRemoved": "Proxy removido",
|
||||
"proxyUpdated": "Proxy atualizado",
|
||||
"roleCreated": "Função criada",
|
||||
"roleRemoved": "Função removida",
|
||||
"roleUpdated": "Função atualizada",
|
||||
"searchPerformed": "Pesquisa realizada",
|
||||
"settingsChanged": "Configurações alteradas",
|
||||
"ssoLogin": "Login SSO",
|
||||
"ssoLogout": "Logout SSO",
|
||||
"userCreated": "Usuário criado",
|
||||
"userLogin": "Login de usuário",
|
||||
"userRemoved": "Usuário removido",
|
||||
"userUpdated": "Usuário atualizado"
|
||||
},
|
||||
"forbidden": "O log de auditoria está disponível apenas na edição Pro.",
|
||||
"hideDetails": "Ocultar detalhes",
|
||||
"ip": "IP",
|
||||
"loading": "Carregando...",
|
||||
"noAccounts": "Nenhuma conta encontrada",
|
||||
"noUsers": "Nenhum usuário encontrado",
|
||||
"reset": "Redefinir",
|
||||
"showDetails": "Exibir detalhes",
|
||||
"startDate": "Data de início",
|
||||
"time": "Hora",
|
||||
"title": "Log de auditoria",
|
||||
"user": "Usuário",
|
||||
"userPlaceholder": "nome de usuário"
|
||||
},
|
||||
"auth": {
|
||||
"areYouSureYouWantToLogOut": "Tem certeza que deseja sair?",
|
||||
"invalidPassword": "Senha inválida, por favor, tente novamente.",
|
||||
@@ -497,6 +572,7 @@
|
||||
"sessionExpired": "Sessão Expirada!",
|
||||
"sessionExpiredDesc": "Sua sessão terminou devido à inatividade. Por favor, faça login novamente para continuar.",
|
||||
"somethingWentWrong": "Algo deu errado",
|
||||
"ssoLogin": "Login SSO",
|
||||
"username": "Nome de Usuário",
|
||||
"welcome": "Bem-vindo ao Bichon",
|
||||
"youWillNeedToLogInAgain": "Você precisará fazer login novamente para acessar sua conta."
|
||||
@@ -657,19 +733,22 @@
|
||||
},
|
||||
"import": {
|
||||
"account": "Conta",
|
||||
"chooseFiles": "3. Escolher arquivos",
|
||||
"chooseFiles": "2. Escolher arquivos",
|
||||
"completed": "Importação concluída",
|
||||
"description": "Importar arquivos de e-mail para uma conta local (NoSync). Para arquivos maiores, use a CLI.",
|
||||
"detectedFolder": "Detectado",
|
||||
"detectedFrom": "Detectado de",
|
||||
"dropHere": "Solte arquivos .eml / .mbox / .pst aqui",
|
||||
"duplicateCount": "{{count}} duplicados ignorados",
|
||||
"duplicateCountHint": "Estas mensagens já estão arquivadas",
|
||||
"failed": "Falha na importação",
|
||||
"failedCount": "{{count}} falharam",
|
||||
"failedDetails": "Itens com falha",
|
||||
"fileCount": "{{count}} arquivos",
|
||||
"folder": "Pasta",
|
||||
"folderMethod": "2. Escolher método de pasta",
|
||||
"folderMethod": "3. Escolher método de pasta",
|
||||
"folderMethodDesc": "Como a pasta de e-mail de destino deve ser determinada?",
|
||||
"folderStructure": "2. Estrutura de pastas",
|
||||
"folderStructure": "3. Estrutura de pastas",
|
||||
"importHistory": "Histórico de importação",
|
||||
"limits": "Máx: EML 100 MB · MBOX {{maxMbox}} MB · PST {{maxPst}} MB. Arquivos maiores → CLI.",
|
||||
"modeCustom": "Digitar um nome de pasta personalizado",
|
||||
@@ -680,6 +759,7 @@
|
||||
"modeHeaderDesc": "Lê X-Gmail-Labels / X-Bichon-Metadata do arquivo. Alternativa: nome do arquivo.",
|
||||
"noAccountFound": "Nenhuma conta encontrada.",
|
||||
"noFileYet": "Nenhum arquivo selecionado",
|
||||
"noFilesSelected": "Nenhum arquivo selecionado",
|
||||
"noMailboxFound": "Nenhuma caixa de correio encontrada.",
|
||||
"noMailboxes": "Nenhuma caixa de correio encontrada nesta conta.",
|
||||
"orClick": "ou clique para navegar",
|
||||
@@ -690,8 +770,11 @@
|
||||
"searchAccount": "Buscar contas...",
|
||||
"searchMailbox": "Buscar caixas de correio...",
|
||||
"selectAccount": "Selecionar uma conta",
|
||||
"selectAccountAndFiles": "Selecione uma conta de destino e os arquivos primeiro.",
|
||||
"selectAccountFirst": "Selecione uma conta primeiro.",
|
||||
"selectAccountRequired": "Selecione uma conta de destino primeiro.",
|
||||
"selectFileFirst": "Selecione um arquivo primeiro para determinar as opções disponíveis.",
|
||||
"selectFilesRequired": "Selecione os arquivos para importar.",
|
||||
"selectMailbox": "Selecionar caixa de correio...",
|
||||
"source": "origem",
|
||||
"startImport": "Importar",
|
||||
@@ -702,6 +785,46 @@
|
||||
"uploadingFile": "Enviando arquivo",
|
||||
"willImportTo": "Será importado para"
|
||||
},
|
||||
"license": {
|
||||
"accounts": "Contas",
|
||||
"accountsUsed": "{{used}} de {{limit}} usados",
|
||||
"chooseFile": "Escolher arquivo",
|
||||
"copied": "Copiado para a área de transferência",
|
||||
"copyFailed": "Falha ao copiar",
|
||||
"copyMachineId": "Copiar ID da máquina",
|
||||
"description": "Veja os detalhes da sua licença atual e atualize as credenciais.",
|
||||
"edition": "Edição",
|
||||
"features": "Recursos",
|
||||
"forbidden": "O gerenciamento de licenças está disponível apenas na edição Pro.",
|
||||
"licensee": "Licenciado",
|
||||
"loadFailed": "Falha ao carregar o status da licença.",
|
||||
"machineIdDesc": "Identificador único para este dispositivo necessário para gerar uma licença offline.",
|
||||
"machineIdTitle": "ID da máquina",
|
||||
"notAvailable": "N/D",
|
||||
"pasteHere": "Cole o conteúdo da licença aqui...",
|
||||
"readFileFailed": "Falha ao ler o arquivo",
|
||||
"status": "Status",
|
||||
"statusDesc": "Detalhes da sua ativação e recursos atuais",
|
||||
"statusError": "Erro de licença",
|
||||
"statusInvalid": "Assinatura inválida",
|
||||
"statusMachineMismatch": "Incompatibilidade de ID da máquina",
|
||||
"statusTitle": "Status da licença",
|
||||
"statusTrial": "Avaliação",
|
||||
"statusTrialExpired": "Avaliação expirada",
|
||||
"statusUpdateExpired": "Período de atualização expirado",
|
||||
"statusValid": "Válida",
|
||||
"title": "Gerenciamento de licenças",
|
||||
"trialDays": "Dias de avaliação",
|
||||
"trialDaysRemaining": "{{days}} dias restantes",
|
||||
"updatesUntil": "Atualizações até",
|
||||
"upload": "Enviar",
|
||||
"uploadDesc": "Envie seu arquivo de licença ou cole o conteúdo diretamente para aplicar atualizações.",
|
||||
"uploadFailed": "Falha ao enviar a licença",
|
||||
"uploadFailedDesc": "Não foi possível analisar ou validar o arquivo de licença.",
|
||||
"uploadSuccess": "Licença enviada com sucesso",
|
||||
"uploadTitle": "Atualizar licença",
|
||||
"uploading": "Enviando..."
|
||||
},
|
||||
"mail": {
|
||||
"account": "Conta",
|
||||
"attachments": "Anexos",
|
||||
@@ -791,10 +914,12 @@
|
||||
"accounts": "Contas",
|
||||
"apiDocs": "Documentação da API",
|
||||
"attachment": "Anexos",
|
||||
"auditLog": "Registro de auditoria",
|
||||
"auth": "Autenticação",
|
||||
"dashboard": "Painel",
|
||||
"general": "Geral",
|
||||
"home": "Início",
|
||||
"license": "Licença",
|
||||
"mailbox": "Caixa de Entrada",
|
||||
"oauth2": "OAuth2",
|
||||
"other": "Outro",
|
||||
@@ -1422,6 +1547,10 @@
|
||||
}
|
||||
},
|
||||
"proxy": "Proxy",
|
||||
"proxyTest": "Testar proxy",
|
||||
"proxyTestFailed": "Falha na conexão de proxy.",
|
||||
"proxyTestSuccess": "Conexão de proxy bem-sucedida!",
|
||||
"proxyTesting": "Testando proxy...",
|
||||
"proxyUpdateOrAddFailed": "Falha ao {{action}}, por favor, tente novamente mais tarde",
|
||||
"reset": "Redefinir",
|
||||
"resetRootPassword": "Redefinir Senha Root",
|
||||
|
||||
@@ -98,6 +98,7 @@
|
||||
"allMailSkipped": "Выбраны стандартные папки. Папка 'Вся почта' пропущена во избежание дубликатов.",
|
||||
"areYouSureYouWantTo": "Вы уверены, что хотите {{action}} этот аккаунт?",
|
||||
"auth": "Авторизация",
|
||||
"authPassword": "Пароль аутентификации",
|
||||
"authType": "тип_авторизации",
|
||||
"autoConfiguring": "Автоматическая настройка…",
|
||||
"autoDiscover": "Автоопределение настроек сервера",
|
||||
@@ -116,6 +117,7 @@
|
||||
"clickSaveWhenDone": "Нажмите сохранить, когда закончите.",
|
||||
"continue": "Продолжить",
|
||||
"createdAt": "Создано",
|
||||
"creating": "Создание аккаунта...",
|
||||
"creationFailed": "Ошибка создания, попробуйте позже",
|
||||
"cronAdvanced": "Расширенное выражение",
|
||||
"cronDaily": "Ежедневно",
|
||||
@@ -333,18 +335,26 @@
|
||||
"selectedMailboxes": "Выбранные почтовые ящики",
|
||||
"serverConfiguration": "Конфигурация сервера (IMAP)",
|
||||
"settings": {
|
||||
"backToAccounts": "Назад к аккаунтам",
|
||||
"download": "Скачивание",
|
||||
"downloadDesc": "Настройка времени и способа получения писем с сервера.",
|
||||
"filters": "Фильтры",
|
||||
"filtersDesc": "Управляйте тем, какие письма архивировать. Если фильтрация отключена, будут сохраняться все письма.",
|
||||
"general": "Общие",
|
||||
"generalDesc": "Основная информация об аккаунте и его статус.",
|
||||
"loading": "Загрузка настроек...",
|
||||
"newAccount": "Новый аккаунт",
|
||||
"performance": "Производительность",
|
||||
"reset": "Сбросить настройки",
|
||||
"save": "Сохранить настройки",
|
||||
"saved": "Сохранено",
|
||||
"savedDesc": "Настройки аккаунта успешно сохранены.",
|
||||
"saving": "Сохранение настроек...",
|
||||
"schedule": "Расписание",
|
||||
"scope": "Период синхронизации",
|
||||
"server": "Сервер",
|
||||
"serverDesc": "Настройки подключения IMAP и аутентификация."
|
||||
"serverDesc": "Настройки подключения IMAP и аутентификация.",
|
||||
"settings": "Настройки аккаунта"
|
||||
},
|
||||
"since": "с",
|
||||
"sinceFixed": "С определенной даты",
|
||||
@@ -470,6 +480,7 @@
|
||||
"downloading": "Загрузка...",
|
||||
"emailMessageNotFound": "Не удалось найти исходное письмо. Возможно, оно было удалено.",
|
||||
"name": "Имя файла",
|
||||
"preview": "Предпросмотр вложения",
|
||||
"search_input_placeholder": "Поиск вложений (используйте \" \" для фразового поиска)",
|
||||
"sender": "Отправитель",
|
||||
"sender_with_count": "Отправитель ({{count}})",
|
||||
@@ -487,6 +498,70 @@
|
||||
"zoomIn": "Увеличить масштаб",
|
||||
"zoomOut": "Уменьшить масштаб"
|
||||
},
|
||||
"audit": {
|
||||
"account": "Аккаунт",
|
||||
"accountPlaceholder": "Выберите аккаунт",
|
||||
"allAccounts": "Все аккаунты",
|
||||
"allTypes": "Все типы",
|
||||
"allUsers": "Все пользователи",
|
||||
"apply": "Применить",
|
||||
"detail": "Детали",
|
||||
"empty": "События аудита не найдены",
|
||||
"endDate": "Дата окончания",
|
||||
"eventType": "Тип события",
|
||||
"eventTypes": {
|
||||
"accessTokenCreated": "Создание токена доступа",
|
||||
"accessTokenRemoved": "Удаление токена доступа",
|
||||
"accountCreated": "Создание аккаунта",
|
||||
"accountDownloadStarted": "Запуск синхронизации аккаунта",
|
||||
"accountDownloadStopped": "Остановка синхронизации аккаунта",
|
||||
"accountRemoved": "Удаление аккаунта",
|
||||
"accountRoleAssigned": "Назначение доступа к аккаунту",
|
||||
"accountUpdated": "Обновление аккаунта",
|
||||
"attachmentDownloaded": "Скачивание вложения",
|
||||
"attachmentPreviewed": "Просмотр вложения",
|
||||
"attachmentTagged": "Изменение тегов вложения",
|
||||
"emailDeleted": "Удаление письма",
|
||||
"emailExported": "Экспорт письма",
|
||||
"emailRestored": "Восстановление письма",
|
||||
"emailTagged": "Изменение тегов письма",
|
||||
"emailViewed": "Просмотр письма",
|
||||
"importPerformed": "Выполнение импорта",
|
||||
"licenseUploaded": "Загрузка лицензии",
|
||||
"mailboxRemoved": "Удаление почтового ящика",
|
||||
"oauth2Created": "Создание конфиг. OAuth2",
|
||||
"oauth2Removed": "Удаление конфиг. OAuth2",
|
||||
"oauth2TokenStored": "Сохранение токена OAuth2",
|
||||
"oauth2Updated": "Обновление конфиг. OAuth2",
|
||||
"proxyCreated": "Создание прокси",
|
||||
"proxyRemoved": "Удаление прокси",
|
||||
"proxyUpdated": "Обновление прокси",
|
||||
"roleCreated": "Создание роли",
|
||||
"roleRemoved": "Удаление роли",
|
||||
"roleUpdated": "Обновление роли",
|
||||
"searchPerformed": "Выполнение поиска",
|
||||
"settingsChanged": "Изменение настроек",
|
||||
"ssoLogin": "Вход SSO",
|
||||
"ssoLogout": "Выход SSO",
|
||||
"userCreated": "Создание пользователя",
|
||||
"userLogin": "Вход пользователя",
|
||||
"userRemoved": "Удаление пользователя",
|
||||
"userUpdated": "Обновление пользователя"
|
||||
},
|
||||
"forbidden": "Журнал аудита доступен только в версии Pro.",
|
||||
"hideDetails": "Скрыть детали",
|
||||
"ip": "IP",
|
||||
"loading": "Загрузка...",
|
||||
"noAccounts": "Аккаунты не найдены",
|
||||
"noUsers": "Пользователи не найдены",
|
||||
"reset": "Сбросить",
|
||||
"showDetails": "Показать детали",
|
||||
"startDate": "Дата начала",
|
||||
"time": "Время",
|
||||
"title": "Журнал аудита",
|
||||
"user": "Пользователь",
|
||||
"userPlaceholder": "имя пользователя"
|
||||
},
|
||||
"auth": {
|
||||
"areYouSureYouWantToLogOut": "Вы уверены, что хотите выйти?",
|
||||
"invalidPassword": "Неверный пароль. Пожалуйста, попробуйте снова.",
|
||||
@@ -497,6 +572,7 @@
|
||||
"sessionExpired": "Сессия истекла!",
|
||||
"sessionExpiredDesc": "Ваш сеанс завершен из-за неактивности. Пожалуйста, войдите снова, чтобы продолжить.",
|
||||
"somethingWentWrong": "Что-то пошло не так",
|
||||
"ssoLogin": "Вход через SSO",
|
||||
"username": "Имя пользователя",
|
||||
"welcome": "Добро пожаловать в Bichon",
|
||||
"youWillNeedToLogInAgain": "Вам нужно будет снова войти в систему, чтобы получить доступ к учетной записи."
|
||||
@@ -657,19 +733,22 @@
|
||||
},
|
||||
"import": {
|
||||
"account": "Аккаунт",
|
||||
"chooseFiles": "3. Выбрать файлы",
|
||||
"chooseFiles": "2. Выбрать файлы",
|
||||
"completed": "Импорт завершен",
|
||||
"description": "Импорт файлов писем в локальный аккаунт (NoSync). Для больших файлов используйте CLI.",
|
||||
"detectedFolder": "Обнаружено",
|
||||
"detectedFrom": "Обнаружено из",
|
||||
"dropHere": "Перетащите файлы .eml / .mbox / .pst сюда",
|
||||
"duplicateCount": "Пропущено дубликатов: {{count}}",
|
||||
"duplicateCountHint": "Эти сообщения уже в архиве",
|
||||
"failed": "Ошибка импорта",
|
||||
"failedCount": "Ошибок: {{count}}",
|
||||
"failedDetails": "Неудачные элементы",
|
||||
"fileCount": "{{count}} файлов",
|
||||
"folder": "Папка",
|
||||
"folderMethod": "2. Выберите метод определения папки",
|
||||
"folderMethod": "3. Выберите метод определения папки",
|
||||
"folderMethodDesc": "Как следует определять целевую папку для писем?",
|
||||
"folderStructure": "2. Структура папок",
|
||||
"folderStructure": "3. Структура папок",
|
||||
"importHistory": "История импорта",
|
||||
"limits": "Макс: EML 100 МБ · MBOX {{maxMbox}} МБ · PST {{maxPst}} МБ. Для больших файлов → CLI.",
|
||||
"modeCustom": "Ввести имя папки вручную",
|
||||
@@ -680,6 +759,7 @@
|
||||
"modeHeaderDesc": "Чтение X-Gmail-Labels / X-Bichon-Metadata из файла. Если их нет, используется имя файла.",
|
||||
"noAccountFound": "Аккаунт не найден.",
|
||||
"noFileYet": "Файл еще не выбран",
|
||||
"noFilesSelected": "Файлы не выбраны",
|
||||
"noMailboxFound": "Почтовый ящик не найден.",
|
||||
"noMailboxes": "В этом аккаунте не найдено почтовых ящиков.",
|
||||
"orClick": "или нажмите для обзора",
|
||||
@@ -690,8 +770,11 @@
|
||||
"searchAccount": "Поиск аккаунтов...",
|
||||
"searchMailbox": "Поиск почтовых ящиков...",
|
||||
"selectAccount": "Выберите аккаунт",
|
||||
"selectAccountAndFiles": "Сначала выберите целевой аккаунт и файлы.",
|
||||
"selectAccountFirst": "Сначала выберите аккаунт.",
|
||||
"selectAccountRequired": "Сначала выберите целевой аккаунт.",
|
||||
"selectFileFirst": "Сначала выберите файл, чтобы определить доступные параметры.",
|
||||
"selectFilesRequired": "Выберите файлы для импорта.",
|
||||
"selectMailbox": "Выберите почтовый ящик...",
|
||||
"source": "источник",
|
||||
"startImport": "Импортировать",
|
||||
@@ -702,6 +785,46 @@
|
||||
"uploadingFile": "Загрузка файла",
|
||||
"willImportTo": "Будет импортировано в"
|
||||
},
|
||||
"license": {
|
||||
"accounts": "Аккаунты",
|
||||
"accountsUsed": "Использовано {{used}} из {{limit}}",
|
||||
"chooseFile": "Выбрать файл",
|
||||
"copied": "Скопировано в буфер обмена",
|
||||
"copyFailed": "Не удалось скопировать",
|
||||
"copyMachineId": "Скопировать ID машины",
|
||||
"description": "Просмотрите сведения о текущей лицензии и обновите учетные данные.",
|
||||
"edition": "Издание",
|
||||
"features": "Функции",
|
||||
"forbidden": "Управление лицензиями доступно только в версии Pro.",
|
||||
"licensee": "Лицензиат",
|
||||
"loadFailed": "Не удалось загрузить статус лицензии.",
|
||||
"machineIdDesc": "Уникальный идентификатор этого устройства, необходимый для создания автономной лицензии.",
|
||||
"machineIdTitle": "Идентификатор машины",
|
||||
"notAvailable": "Н/Д",
|
||||
"pasteHere": "Вставьте содержимое лицензии сюда...",
|
||||
"readFileFailed": "Не удалось прочитать файл",
|
||||
"status": "Статус",
|
||||
"statusDesc": "Сведения о текущей активации и функциях",
|
||||
"statusError": "Ошибка лицензии",
|
||||
"statusInvalid": "Недействительная подпись",
|
||||
"statusMachineMismatch": "Несовпадение ID машины",
|
||||
"statusTitle": "Статус лицензии",
|
||||
"statusTrial": "Пробный период",
|
||||
"statusTrialExpired": "Пробный период истек",
|
||||
"statusUpdateExpired": "Срок обновлений истек",
|
||||
"statusValid": "Действительна",
|
||||
"title": "Управление лицензиями",
|
||||
"trialDays": "Дни пробного периода",
|
||||
"trialDaysRemaining": "Осталось дней: {{days}}",
|
||||
"updatesUntil": "Обновления до",
|
||||
"upload": "Загрузить",
|
||||
"uploadDesc": "Загрузите файл лицензии или вставьте содержимое напрямую, чтобы применить обновления.",
|
||||
"uploadFailed": "Не удалось загрузить лицензию",
|
||||
"uploadFailedDesc": "Не удалось распознать или проверить файл лицензии.",
|
||||
"uploadSuccess": "Лицензия успешно загружена",
|
||||
"uploadTitle": "Обновить лицензию",
|
||||
"uploading": "Загрузка..."
|
||||
},
|
||||
"mail": {
|
||||
"account": "Аккаунт",
|
||||
"attachments": "Вложения",
|
||||
@@ -791,10 +914,12 @@
|
||||
"accounts": "Учетные записи",
|
||||
"apiDocs": "Документация API",
|
||||
"attachment": "Вложения",
|
||||
"auditLog": "Журнал аудита",
|
||||
"auth": "Авторизация",
|
||||
"dashboard": "Дашборд",
|
||||
"general": "Общие",
|
||||
"home": "Главная",
|
||||
"license": "Лицензия",
|
||||
"mailbox": "Почтовый ящик",
|
||||
"oauth2": "OAuth2",
|
||||
"other": "Другое",
|
||||
@@ -1422,6 +1547,10 @@
|
||||
}
|
||||
},
|
||||
"proxy": "Прокси",
|
||||
"proxyTest": "Проверить прокси",
|
||||
"proxyTestFailed": "Ошибка прокси-соединения.",
|
||||
"proxyTestSuccess": "Прокси-соединение успешно установлено!",
|
||||
"proxyTesting": "Проверка прокси...",
|
||||
"proxyUpdateOrAddFailed": "{{action}} не удалось, попробуйте позже",
|
||||
"reset": "Сброс",
|
||||
"resetRootPassword": "Сбросить Root пароль",
|
||||
|
||||
@@ -98,6 +98,7 @@
|
||||
"allMailSkipped": "Valde standardmappar. \"All e-post\" hoppades över för att undvika dubbletter.",
|
||||
"areYouSureYouWantTo": "Är du säker på att du vill {{action}} detta konto?",
|
||||
"auth": "Auth",
|
||||
"authPassword": "Autentiseringslösenord",
|
||||
"authType": "auth_typ",
|
||||
"autoConfiguring": "Konfigurerar automatiskt…",
|
||||
"autoDiscover": "Hitta serverinställningar automatiskt",
|
||||
@@ -116,6 +117,7 @@
|
||||
"clickSaveWhenDone": "Klicka på spara när du är klar.",
|
||||
"continue": "Fortsätt",
|
||||
"createdAt": "Skapad",
|
||||
"creating": "Skapar konto...",
|
||||
"creationFailed": "Skapande misslyckades, försök igen senare",
|
||||
"cronAdvanced": "Avancerat uttryck",
|
||||
"cronDaily": "Dagligen",
|
||||
@@ -333,18 +335,26 @@
|
||||
"selectedMailboxes": "Valda brevlådor",
|
||||
"serverConfiguration": "Serverkonfiguration (IMAP)",
|
||||
"settings": {
|
||||
"backToAccounts": "Tillbaka till konton",
|
||||
"download": "Ladda ner",
|
||||
"downloadDesc": "Konfigurera när och hur e-postmeddelanden hämtas från serveren.",
|
||||
"filters": "Filter",
|
||||
"filtersDesc": "Styr vilka e-postmeddelanden som arkiveras. När filtrering är inaktiverad sparas alla e-postmeddelanden.",
|
||||
"general": "Allmänt",
|
||||
"generalDesc": "Grundläggande kontoinformation och status.",
|
||||
"loading": "Laddar inställningar...",
|
||||
"newAccount": "Nytt konto",
|
||||
"performance": "Prestanda",
|
||||
"reset": "Återställ inställningar",
|
||||
"save": "Spara inställningar",
|
||||
"saved": "Sparad",
|
||||
"savedDesc": "Kontoinställningarna har sparats.",
|
||||
"saving": "Sparar inställningar...",
|
||||
"schedule": "Tidsplan",
|
||||
"scope": "Omfång",
|
||||
"server": "Server",
|
||||
"serverDesc": "IMAP-anslutningsinställningar och autentisering."
|
||||
"serverDesc": "IMAP-anslutningsinställningar och autentisering.",
|
||||
"settings": "Kontoinställningar"
|
||||
},
|
||||
"since": "sedan",
|
||||
"sinceFixed": "Sedan specifikt datum",
|
||||
@@ -470,6 +480,7 @@
|
||||
"downloading": "Laddar ner...",
|
||||
"emailMessageNotFound": "Det går inte att hitta det ursprungliga e-postmeddelandet. Det kan ha raderats.",
|
||||
"name": "Filnamn",
|
||||
"preview": "Förhandsgranska bilaga",
|
||||
"search_input_placeholder": "Sök efter bilagor (använd \" \" för frassökning)",
|
||||
"sender": "Avsändare",
|
||||
"sender_with_count": "Avsändare ({{count}})",
|
||||
@@ -487,6 +498,70 @@
|
||||
"zoomIn": "Zooma in",
|
||||
"zoomOut": "Zooma ut"
|
||||
},
|
||||
"audit": {
|
||||
"account": "Konto",
|
||||
"accountPlaceholder": "Välj konto",
|
||||
"allAccounts": "Alla konton",
|
||||
"allTypes": "Alla typer",
|
||||
"allUsers": "Alla användare",
|
||||
"apply": "Verkställ",
|
||||
"detail": "Detalj",
|
||||
"empty": "Inga granskningshändelser hittades",
|
||||
"endDate": "Slutdatum",
|
||||
"eventType": "Händelsetyp",
|
||||
"eventTypes": {
|
||||
"accessTokenCreated": "Åtkomsttoken skapad",
|
||||
"accessTokenRemoved": "Åtkomsttoken borttagen",
|
||||
"accountCreated": "Konto skapat",
|
||||
"accountDownloadStarted": "Kontosynkronisering startad",
|
||||
"accountDownloadStopped": "Kontosynkronisering stoppad",
|
||||
"accountRemoved": "Konto borttaget",
|
||||
"accountRoleAssigned": "Kontoåtkomst tilldelad",
|
||||
"accountUpdated": "Konto uppdaterat",
|
||||
"attachmentDownloaded": "Bilaga nedladdad",
|
||||
"attachmentPreviewed": "Bilaga förhandsgranskad",
|
||||
"attachmentTagged": "Bilagsetiketter ändrade",
|
||||
"emailDeleted": "E-post raderad",
|
||||
"emailExported": "E-post exporterad",
|
||||
"emailRestored": "E-post återställd",
|
||||
"emailTagged": "E-postetiketter ändrade",
|
||||
"emailViewed": "E-post visad",
|
||||
"importPerformed": "Import utförd",
|
||||
"licenseUploaded": "Licens uppladdad",
|
||||
"mailboxRemoved": "E-postlåda borttagen",
|
||||
"oauth2Created": "OAuth2-konfiguration skapad",
|
||||
"oauth2Removed": "OAuth2-konfiguration borttagen",
|
||||
"oauth2TokenStored": "OAuth2-token sparad",
|
||||
"oauth2Updated": "OAuth2-konfiguration uppdaterad",
|
||||
"proxyCreated": "Proxy skapad",
|
||||
"proxyRemoved": "Proxy borttagen",
|
||||
"proxyUpdated": "Proxy uppdaterad",
|
||||
"roleCreated": "Roll skapad",
|
||||
"roleRemoved": "Roll borttagen",
|
||||
"roleUpdated": "Roll uppdaterad",
|
||||
"searchPerformed": "Sökning utförd",
|
||||
"settingsChanged": "Inställningar ändrade",
|
||||
"ssoLogin": "SSO-inloggning",
|
||||
"ssoLogout": "SSO-utloggning",
|
||||
"userCreated": "Användare skapad",
|
||||
"userLogin": "Användarinloggning",
|
||||
"userRemoved": "Användare borttagen",
|
||||
"userUpdated": "Användare uppdaterad"
|
||||
},
|
||||
"forbidden": "Granskningsloggen är endast tillgänglig i Pro-utgåvan.",
|
||||
"hideDetails": "Dölj detaljer",
|
||||
"ip": "IP",
|
||||
"loading": "Laddar...",
|
||||
"noAccounts": "Inga konton hittades",
|
||||
"noUsers": "Inga användare hittades",
|
||||
"reset": "Återställ",
|
||||
"showDetails": "Visa detaljer",
|
||||
"startDate": "Startdatum",
|
||||
"time": "Tid",
|
||||
"title": "Granskningslogg",
|
||||
"user": "Användare",
|
||||
"userPlaceholder": "användarnamn"
|
||||
},
|
||||
"auth": {
|
||||
"areYouSureYouWantToLogOut": "Är du säker på att du vill logga ut?",
|
||||
"invalidPassword": "Ogiltigt lösenord. Var god försök igen.",
|
||||
@@ -497,6 +572,7 @@
|
||||
"sessionExpired": "Sessionen har löpt ut!",
|
||||
"sessionExpiredDesc": "Din session har avslutas på grund av inaktivitet. Vänligen logga in igen för att fortsätta.",
|
||||
"somethingWentWrong": "Något gick fel",
|
||||
"ssoLogin": "SSO-inloggning",
|
||||
"username": "Användarnamn",
|
||||
"welcome": "Välkommen till Bichon",
|
||||
"youWillNeedToLogInAgain": "Du måste logga in igen för att komma åt ditt konto."
|
||||
@@ -657,19 +733,22 @@
|
||||
},
|
||||
"import": {
|
||||
"account": "Konto",
|
||||
"chooseFiles": "3. Välj filer",
|
||||
"chooseFiles": "2. Välj filer",
|
||||
"completed": "Import slutförd",
|
||||
"description": "Importera e-postfiler till ett lokalt konto (NoSync). Använd CLI för större filer.",
|
||||
"detectedFolder": "Identifierad",
|
||||
"detectedFrom": "Identifierad från",
|
||||
"dropHere": "Släpp .eml / .mbox / .pst-filer här",
|
||||
"duplicateCount": "{{count}} dubbletter hoppades över",
|
||||
"duplicateCountHint": "Dessa meddelanden är redan arkiverade",
|
||||
"failed": "Import misslyckades",
|
||||
"failedCount": "{{count}} misslyckades",
|
||||
"failedDetails": "Misslyckade objekt",
|
||||
"fileCount": "{{count}} filer",
|
||||
"folder": "Mapp",
|
||||
"folderMethod": "2. Välj mappemetod",
|
||||
"folderMethod": "3. Välj mappemetod",
|
||||
"folderMethodDesc": "Hur ska målmappen for e-post bestämmas?",
|
||||
"folderStructure": "2. Mappstruktur",
|
||||
"folderStructure": "3. Mappstruktur",
|
||||
"importHistory": "Importhistorik",
|
||||
"limits": "Max: EML 100 MB · MBOX {{maxMbox}} MB · PST {{maxPst}} MB. Større filer → CLI.",
|
||||
"modeCustom": "Ange ett anpassat mappnamn",
|
||||
@@ -680,6 +759,7 @@
|
||||
"modeHeaderDesc": "Läser X-Gmail-Labels / X-Bichon-Metadata från filen. Faller tillbaka på filnamn.",
|
||||
"noAccountFound": "Inget konto hittades.",
|
||||
"noFileYet": "Ingen fil har valts än",
|
||||
"noFilesSelected": "Inga filer valda",
|
||||
"noMailboxFound": "Ingen brevlåda hittades.",
|
||||
"noMailboxes": "Inga brevlådor hittades på detta konto.",
|
||||
"orClick": "eller klicka för att bläddra",
|
||||
@@ -690,8 +770,11 @@
|
||||
"searchAccount": "Sök konton...",
|
||||
"searchMailbox": "Sök brevlådor...",
|
||||
"selectAccount": "Välj ett konto",
|
||||
"selectAccountAndFiles": "Välj ett målkonto och filer först.",
|
||||
"selectAccountFirst": "Välj ett konto först.",
|
||||
"selectAccountRequired": "Välj ett målkonto först.",
|
||||
"selectFileFirst": "Välj en fil först för att se tillgängliga alternativ.",
|
||||
"selectFilesRequired": "Välj filer att importera.",
|
||||
"selectMailbox": "Välj en brevlåda...",
|
||||
"source": "källa",
|
||||
"startImport": "Importera",
|
||||
@@ -702,6 +785,46 @@
|
||||
"uploadingFile": "Laddar upp fil",
|
||||
"willImportTo": "Kommer att importeras till"
|
||||
},
|
||||
"license": {
|
||||
"accounts": "Konton",
|
||||
"accountsUsed": "{{used}} av {{limit}} använda",
|
||||
"chooseFile": "Välj fil",
|
||||
"copied": "Kopierat till urklipp",
|
||||
"copyFailed": "Det gick inte att kopiera",
|
||||
"copyMachineId": "Kopiera maskin-ID",
|
||||
"description": "Visa dina aktuella licensdetaljer och uppdatera autentiseringsuppgifter.",
|
||||
"edition": "Utgåva",
|
||||
"features": "Funktioner",
|
||||
"forbidden": "Licenshantering är endast tillgänglig i Pro-utgåvan.",
|
||||
"licensee": "Licenstagare",
|
||||
"loadFailed": "Det gick inte att ladda licensstatus.",
|
||||
"machineIdDesc": "Unik identifierare för denna enhet som krävs för att generera en offlinelicens.",
|
||||
"machineIdTitle": "Maskin-ID",
|
||||
"notAvailable": "Ej tillgänglig",
|
||||
"pasteHere": "Klistra in licensinnehåll här...",
|
||||
"readFileFailed": "Det gick inte att läsa filen",
|
||||
"status": "Status",
|
||||
"statusDesc": "Dina aktuella aktiverings- och funktionsdetaljer",
|
||||
"statusError": "Licensfel",
|
||||
"statusInvalid": "Ogiltig signatur",
|
||||
"statusMachineMismatch": "Maskin-ID stämmer inte",
|
||||
"statusTitle": "Licensstatus",
|
||||
"statusTrial": "Testperiod",
|
||||
"statusTrialExpired": "Testperioden har gått ut",
|
||||
"statusUpdateExpired": "Uppdateringsperioden har gått ut",
|
||||
"statusValid": "Giltig",
|
||||
"title": "Licenshantering",
|
||||
"trialDays": "Testdagar",
|
||||
"trialDaysRemaining": "{{days}} dagar kvar",
|
||||
"updatesUntil": "Uppdateringar till",
|
||||
"upload": "Ladda upp",
|
||||
"uploadDesc": "Ladda upp din licensfil eller klistra in innehållet direkt för att tillämpa uppdateringar.",
|
||||
"uploadFailed": "Det gick inte att ladda upp licensen",
|
||||
"uploadFailedDesc": "Det gick inte att tolka eller validera licensfilen.",
|
||||
"uploadSuccess": "Licensen har laddats upp",
|
||||
"uploadTitle": "Uppdatera licens",
|
||||
"uploading": "Laddar upp..."
|
||||
},
|
||||
"mail": {
|
||||
"account": "Konto",
|
||||
"attachments": "Bilagor",
|
||||
@@ -791,10 +914,12 @@
|
||||
"accounts": "Konton",
|
||||
"apiDocs": "API-dokumentation",
|
||||
"attachment": "Bilagor",
|
||||
"auditLog": "Granskningslogg",
|
||||
"auth": "Autentisering",
|
||||
"dashboard": "Översikt",
|
||||
"general": "Allmänt",
|
||||
"home": "Hem",
|
||||
"license": "Licens",
|
||||
"mailbox": "Brevlåda",
|
||||
"oauth2": "OAuth2",
|
||||
"other": "Övrigt",
|
||||
@@ -1422,6 +1547,10 @@
|
||||
}
|
||||
},
|
||||
"proxy": "Proxy",
|
||||
"proxyTest": "Testa proxy",
|
||||
"proxyTestFailed": "Proxyanslutningen misslyckades.",
|
||||
"proxyTestSuccess": "Proxyanslutningen lyckades!",
|
||||
"proxyTesting": "Testar proxy...",
|
||||
"proxyUpdateOrAddFailed": "{{action}} misslyckades, försök igen senare",
|
||||
"reset": "Återställ",
|
||||
"resetRootPassword": "Återställ root-lösenord",
|
||||
|
||||
@@ -98,6 +98,7 @@
|
||||
"allMailSkipped": "標準資料夾已選擇,為避免重複已跳過「所有郵件」。",
|
||||
"areYouSureYouWantTo": "你確定要{{action}}此帳戶嗎?",
|
||||
"auth": "驗證",
|
||||
"authPassword": "驗證密碼",
|
||||
"authType": "驗證類型",
|
||||
"autoConfiguring": "正在自動設定…",
|
||||
"autoDiscover": "自動偵測伺服器設定",
|
||||
@@ -116,6 +117,7 @@
|
||||
"clickSaveWhenDone": "完成後點擊「儲存」。",
|
||||
"continue": "繼續",
|
||||
"createdAt": "建立時間",
|
||||
"creating": "正在建立帳號...",
|
||||
"creationFailed": "建立失敗,請稍後再試。",
|
||||
"cronAdvanced": "高級表達式",
|
||||
"cronDaily": "每天",
|
||||
@@ -333,18 +335,26 @@
|
||||
"selectedMailboxes": "已選擇的郵件夾",
|
||||
"serverConfiguration": "伺服器設定 (IMAP)",
|
||||
"settings": {
|
||||
"backToAccounts": "返回帳號列表",
|
||||
"download": "下載設定",
|
||||
"downloadDesc": "設定從伺服器獲取與收取郵件的时间与方式。",
|
||||
"filters": "篩選器",
|
||||
"filtersDesc": "控制哪些郵件需要封存。關閉篩選時,將儲存所有郵件。",
|
||||
"general": "基本資訊",
|
||||
"generalDesc": "基本帳戶資訊与狀態。",
|
||||
"loading": "正在載入設定...",
|
||||
"newAccount": "建立新帳戶",
|
||||
"performance": "效能",
|
||||
"reset": "重設設定",
|
||||
"save": "儲存設定",
|
||||
"saved": "已儲存",
|
||||
"savedDesc": "帳號設定已成功儲存。",
|
||||
"saving": "正在儲存設定...",
|
||||
"schedule": "時間排程",
|
||||
"scope": "下載範圍",
|
||||
"server": "伺服器設定",
|
||||
"serverDesc": "IMAP 連線設定與驗證。"
|
||||
"serverDesc": "IMAP 連線設定與驗證。",
|
||||
"settings": "帳號設定"
|
||||
},
|
||||
"since": "自",
|
||||
"sinceFixed": "自特定日期起",
|
||||
@@ -470,6 +480,7 @@
|
||||
"downloading": "正在下載...",
|
||||
"emailMessageNotFound": "找不到原始郵件。它可能已被刪除。",
|
||||
"name": "檔案名稱",
|
||||
"preview": "預覽附件",
|
||||
"search_input_placeholder": "搜尋附件(使用 \" \" 進行短語搜尋)",
|
||||
"sender": "寄件人",
|
||||
"sender_with_count": "寄件人 ({{count}})",
|
||||
@@ -487,6 +498,70 @@
|
||||
"zoomIn": "放大",
|
||||
"zoomOut": "縮小"
|
||||
},
|
||||
"audit": {
|
||||
"account": "郵箱帳號",
|
||||
"accountPlaceholder": "選擇郵箱帳號",
|
||||
"allAccounts": "所有郵箱帳號",
|
||||
"allTypes": "所有類型",
|
||||
"allUsers": "所有使用者",
|
||||
"apply": "套用",
|
||||
"detail": "詳細資料",
|
||||
"empty": "未找到稽核事件",
|
||||
"endDate": "結束日期",
|
||||
"eventType": "事件類型",
|
||||
"eventTypes": {
|
||||
"accessTokenCreated": "建立存取權標",
|
||||
"accessTokenRemoved": "刪除存取權標",
|
||||
"accountCreated": "建立帳號",
|
||||
"accountDownloadStarted": "開始帳號同步",
|
||||
"accountDownloadStopped": "停止帳號同步",
|
||||
"accountRemoved": "刪除帳號",
|
||||
"accountRoleAssigned": "分配帳號權限",
|
||||
"accountUpdated": "更新帳號",
|
||||
"attachmentDownloaded": "下載附件",
|
||||
"attachmentPreviewed": "預覽附件",
|
||||
"attachmentTagged": "修改附件標籤",
|
||||
"emailDeleted": "刪除郵件",
|
||||
"emailExported": "匯出郵件",
|
||||
"emailRestored": "還原郵件",
|
||||
"emailTagged": "修改郵件標籤",
|
||||
"emailViewed": "檢視郵件",
|
||||
"importPerformed": "執行匯入",
|
||||
"licenseUploaded": "上傳授權許可",
|
||||
"mailboxRemoved": "刪除郵箱",
|
||||
"oauth2Created": "建立 OAuth2 設定",
|
||||
"oauth2Removed": "刪除 OAuth2 設定",
|
||||
"oauth2TokenStored": "儲存 OAuth2 權標",
|
||||
"oauth2Updated": "更新 OAuth2 設定",
|
||||
"proxyCreated": "建立代理",
|
||||
"proxyRemoved": "刪除代理",
|
||||
"proxyUpdated": "更新代理",
|
||||
"roleCreated": "建立角色",
|
||||
"roleRemoved": "刪除角色",
|
||||
"roleUpdated": "更新角色",
|
||||
"searchPerformed": "執行搜尋",
|
||||
"settingsChanged": "修改設定",
|
||||
"ssoLogin": "SSO 登入",
|
||||
"ssoLogout": "SSO 登出",
|
||||
"userCreated": "建立使用者",
|
||||
"userLogin": "使用者登入",
|
||||
"userRemoved": "刪除使用者",
|
||||
"userUpdated": "更新使用者"
|
||||
},
|
||||
"forbidden": "稽核記錄僅在專業版 (Pro) 中提供。",
|
||||
"hideDetails": "隱藏詳細資料",
|
||||
"ip": "IP",
|
||||
"loading": "載入中…",
|
||||
"noAccounts": "未找到郵箱帳號",
|
||||
"noUsers": "未找到使用者",
|
||||
"reset": "重設",
|
||||
"showDetails": "顯示詳細資料",
|
||||
"startDate": "開始日期",
|
||||
"time": "時間",
|
||||
"title": "稽核記錄",
|
||||
"user": "使用者",
|
||||
"userPlaceholder": "使用者名稱"
|
||||
},
|
||||
"auth": {
|
||||
"areYouSureYouWantToLogOut": "確定要登出嗎?",
|
||||
"invalidPassword": "密碼無效,請再試一次。",
|
||||
@@ -497,6 +572,7 @@
|
||||
"sessionExpired": "連線逾時!",
|
||||
"sessionExpiredDesc": "由於閒置過久,您的連線已終止。請重新登入以繼續。",
|
||||
"somethingWentWrong": "發生錯誤",
|
||||
"ssoLogin": "SSO 單點登入",
|
||||
"username": "使用者名稱",
|
||||
"welcome": "歡迎使用 Bichon",
|
||||
"youWillNeedToLogInAgain": "您將需要再次登入才能存取您的帳號。"
|
||||
@@ -657,19 +733,22 @@
|
||||
},
|
||||
"import": {
|
||||
"account": "帳戶",
|
||||
"chooseFiles": "3. 選擇檔案",
|
||||
"chooseFiles": "2. 選擇檔案",
|
||||
"completed": "匯入完成",
|
||||
"description": "將郵件檔案匯入至本地帳戶 (NoSync)。大檔案請使用 CLI 命令行工具。",
|
||||
"detectedFolder": "已識別",
|
||||
"detectedFrom": "識別自",
|
||||
"dropHere": "將 .eml / .mbox / .pst 檔案拖曳到此處",
|
||||
"duplicateCount": "已略過 {{count}} 個重複項",
|
||||
"duplicateCountHint": "這些郵件已封存",
|
||||
"failed": "匯入失敗",
|
||||
"failedCount": "{{count}} 個失敗",
|
||||
"failedDetails": "失敗詳情",
|
||||
"fileCount": "{{count}} 個檔案",
|
||||
"folder": "資料夾",
|
||||
"folderMethod": "2. 選擇資料夾比對策略",
|
||||
"folderMethod": "3. 選擇資料夾比對策略",
|
||||
"folderMethodDesc": "如何確定匯入 Target 郵件資料夾?",
|
||||
"folderStructure": "2. 資料夾結構",
|
||||
"folderStructure": "3. 資料夾結構",
|
||||
"importHistory": "匯入歷史",
|
||||
"limits": "限制: EML 100 MB · MBOX {{maxMbox}} MB · PST {{maxPst}} MB。超過限制請使用 CLI。",
|
||||
"modeCustom": "指定自訂資料夾名稱",
|
||||
@@ -680,6 +759,7 @@
|
||||
"modeHeaderDesc": "讀取檔案中的 X-Gmail-Labels / X-Bichon-Metadata 標籤,未識別時預設使用檔案名稱。",
|
||||
"noAccountFound": "未找到相關帳戶。",
|
||||
"noFileYet": "尚未選擇任何檔案",
|
||||
"noFilesSelected": "未選擇任何檔案",
|
||||
"noMailboxFound": "未找到郵箱。",
|
||||
"noMailboxes": "該帳戶下未找到任何郵箱。",
|
||||
"orClick": "或點擊瀏覽檔案",
|
||||
@@ -690,8 +770,11 @@
|
||||
"searchAccount": "搜尋帳戶...",
|
||||
"searchMailbox": "搜尋郵箱...",
|
||||
"selectAccount": "選擇帳戶",
|
||||
"selectAccountAndFiles": "請先選擇目標郵箱帳號和檔案。",
|
||||
"selectAccountFirst": "請先選擇一個帳戶。",
|
||||
"selectAccountRequired": "請先選擇目標郵箱帳號。",
|
||||
"selectFileFirst": "請先選擇檔案以確定可用選項。",
|
||||
"selectFilesRequired": "請選擇要匯入的檔案。",
|
||||
"selectMailbox": "選擇郵箱...",
|
||||
"source": "來源",
|
||||
"startImport": "開始匯入",
|
||||
@@ -702,6 +785,46 @@
|
||||
"uploadingFile": "正在上傳檔案",
|
||||
"willImportTo": "將匯入至"
|
||||
},
|
||||
"license": {
|
||||
"accounts": "帳號額度",
|
||||
"accountsUsed": "已使用 {{used}} / 共 {{limit}} 個",
|
||||
"chooseFile": "選擇檔案",
|
||||
"copied": "已複製到剪貼簿",
|
||||
"copyFailed": "複製失敗",
|
||||
"copyMachineId": "複製機器碼",
|
||||
"description": "檢視目前的授權詳情並更新憑證。",
|
||||
"edition": "版本",
|
||||
"features": "功能特性",
|
||||
"forbidden": "授權管理僅在 Pro 版本中可用。",
|
||||
"licensee": "被授權者",
|
||||
"loadFailed": "載入授權狀態失敗。",
|
||||
"machineIdDesc": "產生離線授權所需的目前裝置唯一識別碼。",
|
||||
"machineIdTitle": "機器碼",
|
||||
"notAvailable": "無",
|
||||
"pasteHere": "在此處貼上授權內容...",
|
||||
"readFileFailed": "讀取檔案失敗",
|
||||
"status": "狀態",
|
||||
"statusDesc": "目前的啟用狀態與功能詳情",
|
||||
"statusError": "授權錯誤",
|
||||
"statusInvalid": "簽名無效",
|
||||
"statusMachineMismatch": "機器碼不符合",
|
||||
"statusTitle": "授權狀態",
|
||||
"statusTrial": "試用中",
|
||||
"statusTrialExpired": "試用已過期",
|
||||
"statusUpdateExpired": "更新維護期已過期",
|
||||
"statusValid": "有效",
|
||||
"title": "授權管理",
|
||||
"trialDays": "試用天數",
|
||||
"trialDaysRemaining": "剩餘 {{days}} 天",
|
||||
"updatesUntil": "更新維護期至",
|
||||
"upload": "上傳",
|
||||
"uploadDesc": "上傳授權檔案或直接貼上內容以套用更新。",
|
||||
"uploadFailed": "授權上傳失敗",
|
||||
"uploadFailedDesc": "無法解析或驗證授權檔案。",
|
||||
"uploadSuccess": "授權上傳成功",
|
||||
"uploadTitle": "更新授權",
|
||||
"uploading": "正在上傳..."
|
||||
},
|
||||
"mail": {
|
||||
"account": "帳號",
|
||||
"attachments": "附件",
|
||||
@@ -791,10 +914,12 @@
|
||||
"accounts": "帳號",
|
||||
"apiDocs": "API文件",
|
||||
"attachment": "附件",
|
||||
"auditLog": "稽核日誌",
|
||||
"auth": "驗證",
|
||||
"dashboard": "儀表板",
|
||||
"general": "一般",
|
||||
"home": "首頁",
|
||||
"license": "授權",
|
||||
"mailbox": "信箱",
|
||||
"oauth2": "OAuth2",
|
||||
"other": "其他",
|
||||
@@ -1422,6 +1547,10 @@
|
||||
}
|
||||
},
|
||||
"proxy": "代理",
|
||||
"proxyTest": "測試代理伺服器",
|
||||
"proxyTestFailed": "代理伺服器連線失敗。",
|
||||
"proxyTestSuccess": "代理伺服器連線成功!",
|
||||
"proxyTesting": "正在測試代理伺服器...",
|
||||
"proxyUpdateOrAddFailed": "{{action}} 失敗,請稍後再試",
|
||||
"reset": "重設",
|
||||
"resetRootPassword": "重設根目錄密碼",
|
||||
|
||||
@@ -98,7 +98,7 @@
|
||||
"allMailSkipped": "已选择标准文件夹。已跳过'所有邮件'以避免重复。",
|
||||
"areYouSureYouWantTo": "你确定要{{action}}此账户吗?",
|
||||
"auth": "认证",
|
||||
"authPassword": "密码",
|
||||
"authPassword": "认证密码",
|
||||
"authType": "认证类型",
|
||||
"autoConfiguring": "正在自动配置…",
|
||||
"autoDiscover": "自动检测服务器设置",
|
||||
@@ -117,7 +117,7 @@
|
||||
"clickSaveWhenDone": "完成后请点击保存。",
|
||||
"continue": "继续",
|
||||
"createdAt": "创建时间",
|
||||
"creating": "创建中...",
|
||||
"creating": "正在创建账号...",
|
||||
"creationFailed": "创建失败,请稍后重试",
|
||||
"cronAdvanced": "高级表达式",
|
||||
"cronDaily": "每天",
|
||||
@@ -335,26 +335,26 @@
|
||||
"selectedMailboxes": "已选择的邮件夹",
|
||||
"serverConfiguration": "服务器配置 (IMAP)",
|
||||
"settings": {
|
||||
"backToAccounts": "返回账户列表",
|
||||
"backToAccounts": "返回账号列表",
|
||||
"download": "下载设置",
|
||||
"downloadDesc": "配置从服务器获取和收取邮件的时间与方式。",
|
||||
"filters": "过滤器",
|
||||
"filtersDesc": "控制哪些邮件需要归档。关闭过滤时,将保存所有邮件。",
|
||||
"general": "基本信息",
|
||||
"generalDesc": "基本账户信息与状态。",
|
||||
"loading": "加载账户中...",
|
||||
"loading": "正在加载设置...",
|
||||
"newAccount": "新建账户",
|
||||
"performance": "性能",
|
||||
"reset": "重置",
|
||||
"save": "保存",
|
||||
"reset": "重置设置",
|
||||
"save": "保存设置",
|
||||
"saved": "已保存",
|
||||
"savedDesc": "设置已成功保存。",
|
||||
"saving": "保存中...",
|
||||
"savedDesc": "账号设置已成功保存。",
|
||||
"saving": "正在保存设置...",
|
||||
"schedule": "时间计划",
|
||||
"scope": "下载范围",
|
||||
"server": "服务器设置",
|
||||
"serverDesc": "IMAP 连接设置与身份验证。",
|
||||
"settings": "设置"
|
||||
"settings": "账号设置"
|
||||
},
|
||||
"since": "自",
|
||||
"sinceFixed": "自特定日期起",
|
||||
@@ -480,6 +480,7 @@
|
||||
"downloading": "正在下载...",
|
||||
"emailMessageNotFound": "找不到原始邮件。它可能已被删除。",
|
||||
"name": "文件名",
|
||||
"preview": "预览附件",
|
||||
"search_input_placeholder": "搜索附件(使用 \" \" 进行短语搜索)",
|
||||
"sender": "发件人",
|
||||
"sender_with_count": "发件人 ({{count}})",
|
||||
@@ -497,6 +498,70 @@
|
||||
"zoomIn": "放大",
|
||||
"zoomOut": "缩小"
|
||||
},
|
||||
"audit": {
|
||||
"account": "邮箱账号",
|
||||
"accountPlaceholder": "选择邮箱账号",
|
||||
"allAccounts": "所有邮箱账号",
|
||||
"allTypes": "所有类型",
|
||||
"allUsers": "所有用户",
|
||||
"apply": "应用",
|
||||
"detail": "详情",
|
||||
"empty": "未找到审计事件",
|
||||
"endDate": "结束日期",
|
||||
"eventType": "事件类型",
|
||||
"eventTypes": {
|
||||
"accessTokenCreated": "创建访问令牌",
|
||||
"accessTokenRemoved": "删除访问令牌",
|
||||
"accountCreated": "创建账号",
|
||||
"accountDownloadStarted": "开始账号同步",
|
||||
"accountDownloadStopped": "停止账号同步",
|
||||
"accountRemoved": "删除账号",
|
||||
"accountRoleAssigned": "分配账号权限",
|
||||
"accountUpdated": "更新账号",
|
||||
"attachmentDownloaded": "下载附件",
|
||||
"attachmentPreviewed": "预览附件",
|
||||
"attachmentTagged": "修改附件标签",
|
||||
"emailDeleted": "删除邮件",
|
||||
"emailExported": "导出邮件",
|
||||
"emailRestored": "还原邮件",
|
||||
"emailTagged": "修改邮件标签",
|
||||
"emailViewed": "查看邮件",
|
||||
"importPerformed": "执行导入",
|
||||
"licenseUploaded": "上传许可证",
|
||||
"mailboxRemoved": "删除邮箱",
|
||||
"oauth2Created": "创建 OAuth2 配置",
|
||||
"oauth2Removed": "删除 OAuth2 配置",
|
||||
"oauth2TokenStored": "保存 OAuth2 令牌",
|
||||
"oauth2Updated": "更新 OAuth2 配置",
|
||||
"proxyCreated": "创建代理",
|
||||
"proxyRemoved": "删除代理",
|
||||
"proxyUpdated": "更新代理",
|
||||
"roleCreated": "创建角色",
|
||||
"roleRemoved": "删除角色",
|
||||
"roleUpdated": "更新角色",
|
||||
"searchPerformed": "执行搜索",
|
||||
"settingsChanged": "修改设置",
|
||||
"ssoLogin": "SSO 登录",
|
||||
"ssoLogout": "SSO 登出",
|
||||
"userCreated": "创建用户",
|
||||
"userLogin": "用户登录",
|
||||
"userRemoved": "删除用户",
|
||||
"userUpdated": "更新用户"
|
||||
},
|
||||
"forbidden": "审计日志仅在专业版 (Pro) 中可用。",
|
||||
"hideDetails": "隐藏详情",
|
||||
"ip": "IP",
|
||||
"loading": "加载中…",
|
||||
"noAccounts": "未找到邮箱账号",
|
||||
"noUsers": "未找到用户",
|
||||
"reset": "重置",
|
||||
"showDetails": "显示详情",
|
||||
"startDate": "开始日期",
|
||||
"time": "时间",
|
||||
"title": "审计日志",
|
||||
"user": "用户",
|
||||
"userPlaceholder": "用户名"
|
||||
},
|
||||
"auth": {
|
||||
"areYouSureYouWantToLogOut": "您确定要退出登录吗?",
|
||||
"invalidPassword": "密码无效,请重试。",
|
||||
@@ -507,6 +572,7 @@
|
||||
"sessionExpired": "会话已过期!",
|
||||
"sessionExpiredDesc": "由于长时间未活动,您的会话已结束。请重新登录以继续。",
|
||||
"somethingWentWrong": "出错了",
|
||||
"ssoLogin": "SSO 单点登录",
|
||||
"username": "用户名",
|
||||
"welcome": "欢迎使用 Bichon",
|
||||
"youWillNeedToLogInAgain": "您需要重新登录才能访问您的账户。"
|
||||
@@ -667,19 +733,22 @@
|
||||
},
|
||||
"import": {
|
||||
"account": "账户",
|
||||
"chooseFiles": "3. 选择文件",
|
||||
"chooseFiles": "2. 选择文件",
|
||||
"completed": "导入完成",
|
||||
"description": "将邮件文件导入至本地账户 (NoSync)。大文件请使用 CLI 命令行工具。",
|
||||
"detectedFolder": "已识别",
|
||||
"detectedFrom": "识别自",
|
||||
"dropHere": "将 .eml / .mbox / .pst 文件拖拽到此处",
|
||||
"duplicateCount": "已跳过 {{count}} 个重复项",
|
||||
"duplicateCountHint": "这些邮件已归档",
|
||||
"failed": "导入失败",
|
||||
"failedCount": "{{count}} 个失败",
|
||||
"failedDetails": "失败详情",
|
||||
"fileCount": "{{count}} 个文件",
|
||||
"folder": "文件夹",
|
||||
"folderMethod": "2. 选择文件夹匹配策略",
|
||||
"folderMethod": "3. 选择文件夹匹配策略",
|
||||
"folderMethodDesc": "如何确定导入的目标邮件文件夹?",
|
||||
"folderStructure": "2. 文件夹结构",
|
||||
"folderStructure": "3. 文件夹结构",
|
||||
"importHistory": "导入历史",
|
||||
"limits": "限制: EML 100 MB · MBOX {{maxMbox}} MB · PST {{maxPst}} MB。超过限制请使用 CLI。",
|
||||
"modeCustom": "指定自定义文件夹名称",
|
||||
@@ -690,6 +759,7 @@
|
||||
"modeHeaderDesc": "读取文件中的 X-Gmail-Labels / X-Bichon-Metadata 标签,未识别时默认使用文件名。",
|
||||
"noAccountFound": "未找到相关账户。",
|
||||
"noFileYet": "尚未选择任何文件",
|
||||
"noFilesSelected": "未选择任何文件",
|
||||
"noMailboxFound": "未找到邮箱。",
|
||||
"noMailboxes": "该账户下未找到任何邮箱。",
|
||||
"orClick": "或点击浏览文件",
|
||||
@@ -700,8 +770,11 @@
|
||||
"searchAccount": "搜索账户...",
|
||||
"searchMailbox": "搜索邮箱...",
|
||||
"selectAccount": "选择账户",
|
||||
"selectAccountAndFiles": "请先选择目标邮箱账号和文件。",
|
||||
"selectAccountFirst": "请先选择一个账户。",
|
||||
"selectAccountRequired": "请先选择目标邮箱账号。",
|
||||
"selectFileFirst": "请先选择文件以确定可用选项。",
|
||||
"selectFilesRequired": "请选择要导入的文件。",
|
||||
"selectMailbox": "选择邮箱...",
|
||||
"source": "来源",
|
||||
"startImport": "开始导入",
|
||||
@@ -712,6 +785,46 @@
|
||||
"uploadingFile": "正在上传文件",
|
||||
"willImportTo": "将导入至"
|
||||
},
|
||||
"license": {
|
||||
"accounts": "账号额度",
|
||||
"accountsUsed": "已使用 {{used}} / 共 {{limit}} 个",
|
||||
"chooseFile": "选择文件",
|
||||
"copied": "已复制到剪贴板",
|
||||
"copyFailed": "复制失败",
|
||||
"copyMachineId": "复制机器码",
|
||||
"description": "查看当前许可证详情并更新凭证。",
|
||||
"edition": "版本",
|
||||
"features": "功能特性",
|
||||
"forbidden": "许可证管理仅在 Pro 版本中可用。",
|
||||
"licensee": "被许可方",
|
||||
"loadFailed": "加载许可证状态失败。",
|
||||
"machineIdDesc": "生成离线许可证所需的当前设备唯一标识。",
|
||||
"machineIdTitle": "机器码",
|
||||
"notAvailable": "无",
|
||||
"pasteHere": "在此处粘贴许可证内容...",
|
||||
"readFileFailed": "读取文件失败",
|
||||
"status": "状态",
|
||||
"statusDesc": "当前激活状态和功能详情",
|
||||
"statusError": "许可证错误",
|
||||
"statusInvalid": "签名无效",
|
||||
"statusMachineMismatch": "机器码不匹配",
|
||||
"statusTitle": "许可证状态",
|
||||
"statusTrial": "试用中",
|
||||
"statusTrialExpired": "试用已过期",
|
||||
"statusUpdateExpired": "更新维护期已过期",
|
||||
"statusValid": "有效",
|
||||
"title": "许可证管理",
|
||||
"trialDays": "试用天数",
|
||||
"trialDaysRemaining": "剩余 {{days}} 天",
|
||||
"updatesUntil": "更新维护期至",
|
||||
"upload": "上传",
|
||||
"uploadDesc": "上传许可证文件或直接粘贴内容以应用更新。",
|
||||
"uploadFailed": "许可证上传失败",
|
||||
"uploadFailedDesc": "无法解析或验证许可证文件。",
|
||||
"uploadSuccess": "许可证上传成功",
|
||||
"uploadTitle": "更新许可证",
|
||||
"uploading": "正在上传..."
|
||||
},
|
||||
"mail": {
|
||||
"account": "账户",
|
||||
"attachments": "附件",
|
||||
@@ -801,10 +914,12 @@
|
||||
"accounts": "账户",
|
||||
"apiDocs": "API 文档",
|
||||
"attachment": "附件",
|
||||
"auditLog": "审计日志",
|
||||
"auth": "认证",
|
||||
"dashboard": "仪表板",
|
||||
"general": "常规",
|
||||
"home": "首页",
|
||||
"license": "许可证",
|
||||
"mailbox": "邮箱",
|
||||
"oauth2": "OAuth2",
|
||||
"other": "其他",
|
||||
@@ -1432,6 +1547,10 @@
|
||||
}
|
||||
},
|
||||
"proxy": "网络代理",
|
||||
"proxyTest": "测试代理",
|
||||
"proxyTestFailed": "代理连接失败。",
|
||||
"proxyTestSuccess": "代理连接成功!",
|
||||
"proxyTesting": "正在测试代理...",
|
||||
"proxyUpdateOrAddFailed": "{{action}}失败,请稍后重试",
|
||||
"reset": "重置",
|
||||
"resetRootPassword": "重置root账户密码",
|
||||
|
||||
@@ -23,6 +23,12 @@ import { Route as AuthenticatedAttachmentIndexImport } from './routes/_authentic
|
||||
|
||||
// Create Virtual Routes
|
||||
|
||||
const AuthenticatedLicenseLazyImport = createFileRoute(
|
||||
'/_authenticated/license',
|
||||
)()
|
||||
const AuthenticatedAuditLogLazyImport = createFileRoute(
|
||||
'/_authenticated/audit-log',
|
||||
)()
|
||||
const errors503LazyImport = createFileRoute('/(errors)/503')()
|
||||
const errors500LazyImport = createFileRoute('/(errors)/500')()
|
||||
const errors404LazyImport = createFileRoute('/(errors)/404')()
|
||||
@@ -96,6 +102,22 @@ const AuthenticatedIndexRoute = AuthenticatedIndexImport.update({
|
||||
getParentRoute: () => AuthenticatedRouteRoute,
|
||||
} as any)
|
||||
|
||||
const AuthenticatedLicenseLazyRoute = AuthenticatedLicenseLazyImport.update({
|
||||
id: '/license',
|
||||
path: '/license',
|
||||
getParentRoute: () => AuthenticatedRouteRoute,
|
||||
} as any).lazy(() =>
|
||||
import('./routes/_authenticated/license.lazy').then((d) => d.Route),
|
||||
)
|
||||
|
||||
const AuthenticatedAuditLogLazyRoute = AuthenticatedAuditLogLazyImport.update({
|
||||
id: '/audit-log',
|
||||
path: '/audit-log',
|
||||
getParentRoute: () => AuthenticatedRouteRoute,
|
||||
} as any).lazy(() =>
|
||||
import('./routes/_authenticated/audit-log.lazy').then((d) => d.Route),
|
||||
)
|
||||
|
||||
const errors503LazyRoute = errors503LazyImport
|
||||
.update({
|
||||
id: '/(errors)/503',
|
||||
@@ -417,6 +439,20 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof errors503LazyImport
|
||||
parentRoute: typeof rootRoute
|
||||
}
|
||||
'/_authenticated/audit-log': {
|
||||
id: '/_authenticated/audit-log'
|
||||
path: '/audit-log'
|
||||
fullPath: '/audit-log'
|
||||
preLoaderRoute: typeof AuthenticatedAuditLogLazyImport
|
||||
parentRoute: typeof AuthenticatedRouteImport
|
||||
}
|
||||
'/_authenticated/license': {
|
||||
id: '/_authenticated/license'
|
||||
path: '/license'
|
||||
fullPath: '/license'
|
||||
preLoaderRoute: typeof AuthenticatedLicenseLazyImport
|
||||
parentRoute: typeof AuthenticatedRouteImport
|
||||
}
|
||||
'/_authenticated/': {
|
||||
id: '/_authenticated/'
|
||||
path: '/'
|
||||
@@ -613,6 +649,8 @@ const AuthenticatedUsersRouteLazyRouteWithChildren =
|
||||
interface AuthenticatedRouteRouteChildren {
|
||||
AuthenticatedSettingsRouteLazyRoute: typeof AuthenticatedSettingsRouteLazyRouteWithChildren
|
||||
AuthenticatedUsersRouteLazyRoute: typeof AuthenticatedUsersRouteLazyRouteWithChildren
|
||||
AuthenticatedAuditLogLazyRoute: typeof AuthenticatedAuditLogLazyRoute
|
||||
AuthenticatedLicenseLazyRoute: typeof AuthenticatedLicenseLazyRoute
|
||||
AuthenticatedIndexRoute: typeof AuthenticatedIndexRoute
|
||||
AuthenticatedAccountsNewLazyRoute: typeof AuthenticatedAccountsNewLazyRoute
|
||||
AuthenticatedAttachmentIndexRoute: typeof AuthenticatedAttachmentIndexRoute
|
||||
@@ -630,6 +668,8 @@ const AuthenticatedRouteRouteChildren: AuthenticatedRouteRouteChildren = {
|
||||
AuthenticatedSettingsRouteLazyRouteWithChildren,
|
||||
AuthenticatedUsersRouteLazyRoute:
|
||||
AuthenticatedUsersRouteLazyRouteWithChildren,
|
||||
AuthenticatedAuditLogLazyRoute: AuthenticatedAuditLogLazyRoute,
|
||||
AuthenticatedLicenseLazyRoute: AuthenticatedLicenseLazyRoute,
|
||||
AuthenticatedIndexRoute: AuthenticatedIndexRoute,
|
||||
AuthenticatedAccountsNewLazyRoute: AuthenticatedAccountsNewLazyRoute,
|
||||
AuthenticatedAttachmentIndexRoute: AuthenticatedAttachmentIndexRoute,
|
||||
@@ -657,6 +697,8 @@ export interface FileRoutesByFullPath {
|
||||
'/403': typeof errors403LazyRoute
|
||||
'/404': typeof errors404LazyRoute
|
||||
'/503': typeof errors503LazyRoute
|
||||
'/audit-log': typeof AuthenticatedAuditLogLazyRoute
|
||||
'/license': typeof AuthenticatedLicenseLazyRoute
|
||||
'/': typeof AuthenticatedIndexRoute
|
||||
'/accounts/new': typeof AuthenticatedAccountsNewLazyRoute
|
||||
'/settings/access': typeof AuthenticatedSettingsAccessLazyRoute
|
||||
@@ -686,6 +728,8 @@ export interface FileRoutesByTo {
|
||||
'/403': typeof errors403LazyRoute
|
||||
'/404': typeof errors404LazyRoute
|
||||
'/503': typeof errors503LazyRoute
|
||||
'/audit-log': typeof AuthenticatedAuditLogLazyRoute
|
||||
'/license': typeof AuthenticatedLicenseLazyRoute
|
||||
'/': typeof AuthenticatedIndexRoute
|
||||
'/accounts/new': typeof AuthenticatedAccountsNewLazyRoute
|
||||
'/settings/access': typeof AuthenticatedSettingsAccessLazyRoute
|
||||
@@ -720,6 +764,8 @@ export interface FileRoutesById {
|
||||
'/(errors)/404': typeof errors404LazyRoute
|
||||
'/(errors)/500': typeof errors500LazyRoute
|
||||
'/(errors)/503': typeof errors503LazyRoute
|
||||
'/_authenticated/audit-log': typeof AuthenticatedAuditLogLazyRoute
|
||||
'/_authenticated/license': typeof AuthenticatedLicenseLazyRoute
|
||||
'/_authenticated/': typeof AuthenticatedIndexRoute
|
||||
'/_authenticated/accounts/new': typeof AuthenticatedAccountsNewLazyRoute
|
||||
'/_authenticated/settings/access': typeof AuthenticatedSettingsAccessLazyRoute
|
||||
@@ -754,6 +800,8 @@ export interface FileRouteTypes {
|
||||
| '/403'
|
||||
| '/404'
|
||||
| '/503'
|
||||
| '/audit-log'
|
||||
| '/license'
|
||||
| '/'
|
||||
| '/accounts/new'
|
||||
| '/settings/access'
|
||||
@@ -782,6 +830,8 @@ export interface FileRouteTypes {
|
||||
| '/403'
|
||||
| '/404'
|
||||
| '/503'
|
||||
| '/audit-log'
|
||||
| '/license'
|
||||
| '/'
|
||||
| '/accounts/new'
|
||||
| '/settings/access'
|
||||
@@ -814,6 +864,8 @@ export interface FileRouteTypes {
|
||||
| '/(errors)/404'
|
||||
| '/(errors)/500'
|
||||
| '/(errors)/503'
|
||||
| '/_authenticated/audit-log'
|
||||
| '/_authenticated/license'
|
||||
| '/_authenticated/'
|
||||
| '/_authenticated/accounts/new'
|
||||
| '/_authenticated/settings/access'
|
||||
@@ -884,6 +936,8 @@ export const routeTree = rootRoute
|
||||
"children": [
|
||||
"/_authenticated/settings",
|
||||
"/_authenticated/users",
|
||||
"/_authenticated/audit-log",
|
||||
"/_authenticated/license",
|
||||
"/_authenticated/",
|
||||
"/_authenticated/accounts/new",
|
||||
"/_authenticated/attachment/",
|
||||
@@ -939,6 +993,14 @@ export const routeTree = rootRoute
|
||||
"/(errors)/503": {
|
||||
"filePath": "(errors)/503.lazy.tsx"
|
||||
},
|
||||
"/_authenticated/audit-log": {
|
||||
"filePath": "_authenticated/audit-log.lazy.tsx",
|
||||
"parent": "/_authenticated"
|
||||
},
|
||||
"/_authenticated/license": {
|
||||
"filePath": "_authenticated/license.lazy.tsx",
|
||||
"parent": "/_authenticated"
|
||||
},
|
||||
"/_authenticated/": {
|
||||
"filePath": "_authenticated/index.tsx",
|
||||
"parent": "/_authenticated"
|
||||
|
||||
12
web/src/routes/_authenticated/audit-log.lazy.tsx
Normal file
12
web/src/routes/_authenticated/audit-log.lazy.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
//
|
||||
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
|
||||
//
|
||||
// Audit log route (Pro edition).
|
||||
//
|
||||
|
||||
import { createLazyFileRoute } from '@tanstack/react-router'
|
||||
import AuditLog from '@/features/audit-log'
|
||||
|
||||
export const Route = createLazyFileRoute('/_authenticated/audit-log')({
|
||||
component: AuditLog,
|
||||
})
|
||||
24
web/src/routes/_authenticated/license.lazy.tsx
Normal file
24
web/src/routes/_authenticated/license.lazy.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
//
|
||||
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
|
||||
//
|
||||
// This file is part of the Bichon Email Archiving Project
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import LicensePage from '@/features/license'
|
||||
import { createLazyFileRoute } from '@tanstack/react-router'
|
||||
|
||||
export const Route = createLazyFileRoute('/_authenticated/license')({
|
||||
component: LicensePage,
|
||||
})
|
||||
Reference in New Issue
Block a user