Compare commits

34 Commits

Author SHA1 Message Date
rustmailer
de871225ae update 2026-08-26 01:11:49 +08:00
rustmailer
388773bd2e update 2026-08-25 17:26:31 +08:00
rustmailer
a0d69f43b6 update 2026-08-25 17:03:25 +08:00
rustmailer
c4a1e36c61 bump to v2.0.2 2026-08-19 17:34:54 +08:00
rustmailer
02b8741725 fix(imap): downgrade empty tail-fetch from error to info #342 2026-08-19 17:31:47 +08:00
rustmailer
d9097f85cf refactor: rename cache module to archive 2026-08-19 17:02:55 +08:00
rustmailer
704678234b Update index.tsx 2026-08-19 15:33:05 +08:00
rustmailer
0d800a48bb Merge pull request #350 from sripwoud/fix/dedup-duplicates-count
fix(core): count deduplicated imports as duplicates, not successes
2026-08-19 15:25:17 +08:00
rustmailer
eaba876de5 refactor: reorder import UI flow to select files before folder 2026-08-19 15:00:06 +08:00
sripwoud
f357d45235 feat(web): reassure on hover that duplicates are already archived
The anxiety behind a surprise duplicates count is "did I lose
something?" — a native title tooltip on the counter answers it:
these messages are already in the archive. Key added to all 18
locales.
2026-08-19 08:45:21 +02:00
sripwoud
46eeab8100 fix(web): localize duplicates counter and hoist processed math
duplicateCount existed only in en.json, so 17 locales fell back to
English inside an otherwise localized panel. The processed count,
percentage and progress bar computed success+failed+duplicates
inline three times; hoist it once so the three can never disagree.
2026-08-19 08:45:21 +02:00
sripwoud
fa98f161c6 fix(core): carry duplicates through the import audit event
success no longer includes dedup-skipped mail, so without a
duplicates field ImportPerformed consumers could not tell an
all-duplicates batch from one that silently lost everything. Also
documents that ExtractOutcome::Imported covers mail dropped by
archive rules, which has always counted as a success.
2026-08-19 08:45:21 +02:00
sripwoud
8eac80e15c style(core): format files touched by the dedup fix
rustfmt +nightly (imports_granularity, group_imports per
.rustfmt.toml) over the five files the fix touched, plus trailing
whitespace cleanup. No behavior change.
2026-08-19 08:45:21 +02:00
sripwoud
e72a53a4d0 fix(web): restore duplicates display in import progress
Reapplies what c067655 reverted: the duplicates counter next to
success/failed and duplicates included in the processed count and
percentage. The server now reports real duplicate counts on every
upload-import path, so the display no longer renders dead zeros.
2026-08-19 08:45:21 +02:00
sripwoud
0899aafbb3 fix(core): count deduplicated imports as duplicates, not successes
A BLAKE3 content-hash hit in extract_envelope_core returned the same
Ok(()) as a real import, so every import surface counted silently
skipped messages as successes and the documented duplicates field
stayed 0 forever.

Make the outcome explicit: extract_envelope_core now returns
ExtractOutcome::{Imported, Duplicate} and every import loop (batch
/import, upload EML, MBOX, PST) counts Duplicate into duplicates
instead of success. total = success + duplicates + failed holds on
every path; duplicates produce no failed_details entries and an
all-duplicates run reports Completed. The batch endpoint status check
treats duplicates as processed work so a duplicates-plus-failures run
keeps reporting Completed as before. The SMTP receiver and IMAP sync
ignore the outcome.
2026-08-19 08:45:21 +02:00
rustmailer
1b2952a6b0 fix: resolve React hooks order violations in account dialogs 2026-08-19 13:54:13 +08:00
rustmailer
ae7a681751 Merge pull request #347 from sripwoud/fix/web-multi-file-import
fix(web): import all selected files, not just the first
2026-08-19 13:38:12 +08:00
rustmailer
2d61f5b555 fix: Dangerous text in migration to 2.x #349 2026-08-19 13:24:12 +08:00
sripwoud
c067655171 revert(web): drop duplicates display until the server reports them
The upload-import path never increments duplicates (a dedup hit
returns Ok and counts as success), so the counter and the progress
math addition could only ever render dead zeros. The display returns
together with the server-side fix; the client-side aggregation of the
duplicates field stays, as issue #2 specifies.
2026-08-18 18:34:02 +02:00
sripwoud
350ec8da1e feat(web): clarify batch import copy and surface duplicates
The import footer showed 'Will import to: X' with no sign that the
whole selection lands in that one folder, and the detected-folder
badge never said which file the hint came from (only the first valid
file is consulted). Multi-file selections now show the file count in
the footer and the hint's source file in the badge.

The results card now renders the aggregated duplicates count and the
processed math includes it. The upload-import path currently always
reports duplicates as 0 (duplicates return Ok and count as success
server-side), so this only becomes visible once the server reports
them, but the aggregate and display are ready.

Header unfolding in extractFolderHint matched any whitespace-led line
in the remaining 64 KB, so body text could be glued onto the folder
name; it now stops at the first non-continuation line per RFC 5322.
2026-08-18 18:23:35 +02:00
sripwoud
9a5816a458 fix(web): import all selected files, not just the first
The file picker allows multi-select but the import mutation only ever
uploaded files[0], silently dropping the rest. upload-import is
one-file-per-call and async: it returns Pending immediately and the
real counts only exist at /import-progress/:id, so summing upload
responses would aggregate zeros.

importFiles() loops the selection sequentially: upload, poll to a
terminal status, merge counts and failed_details into an aggregate.
A file that fails upload or polling becomes a synthetic failure entry
(labelled with the file name) instead of aborting the remaining
uploads; only when every upload transport-fails does it throw so the
existing toast-and-reset path still handles total failure. Upload
progress is byte-weighted across the whole selection so the bar never
resets between files. An AbortSignal wired to component unmount
replaces the deleted setInterval cleanup so polling cannot outlive
the page.

Fixes #2
2026-08-18 17:53:19 +02:00
rustmailer
9e0755c9ed bump to v2.0.1 2026-08-09 17:52:33 +08:00
rustmailer
d27b0f274f update 2026-08-09 17:44:23 +08:00
rustmailer
6e1c75bba8 update 2026-08-09 17:42:49 +08:00
rustmailer
17750e9b80 update 2026-08-09 17:33:02 +08:00
rustmailer
4809f298e9 update 2026-08-09 16:45:00 +08:00
rustmailer
a07e8b3a78 Merge pull request #340 from rustmailer/fix/imap-uid-search-truncation
fix(imap): stop silent mail loss from truncated UID SEARCH enumeration
2026-08-09 16:27:43 +08:00
rustmailer
eb2e4b5393 fix(imap): stop silent mail loss from truncated UID SEARCH enumeration 2026-08-07 19:20:42 +08:00
rustmailer
adb94263c0 feat(audit): audit log page, full event coverage, retention cleanup, and duplicate-view dedup 2026-08-07 00:50:13 +08:00
rustmailer
2d278f956c bump to 2.0.0 2026-08-06 01:25:10 +08:00
rustmailer
ed02c642c8 feat(imap): gap-fill missing-mail repair with live progress UI 2026-08-06 01:18:20 +08:00
rustmailer
75d345b742 feat(imap): resilient batched sync, stale-session cleanup, and live progress UI
- Add SyncFull trigger and configurable IMAP socket read timeout
  - Replace streaming UID FETCH with UID SEARCH ALL + batched fetch so per-message progress stays responsive and throttling servers can retry with reconnection
  - Finalize stale Running sessions on startup so interrupted syncs no longer show a phantom "syncing" state
  - Show a live syncing pill on the account row; add elapsed time, current folder, and slow-server warning styling in the dialog
2026-08-04 17:47:42 +08:00
rustmailer
9b1f6cae8c update 2026-08-03 00:17:00 +08:00
rustmailer
e57d1e41e0 fix(web): consume OIDC access_token and add dual SSO sign-out 2026-08-03 00:04:51 +08:00
93 changed files with 8529 additions and 1305 deletions

34
Cargo.lock generated
View File

@@ -297,9 +297,15 @@ version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]]
name = "base64"
version = "0.23.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b25655df2c3cdd83c5e5b293b88acd880332b2ddadd7c30ac43144fdc0033da9"
[[package]]
name = "bichon-admin"
version = "2.0.1-alpha.1"
version = "2.0.2"
dependencies = [
"bichon-blob",
"bichon-core",
@@ -346,9 +352,9 @@ dependencies = [
[[package]]
name = "bichon-cli"
version = "2.0.1-alpha.1"
version = "2.0.2"
dependencies = [
"base64 0.22.1",
"base64 0.23.0",
"bichon-core",
"chrono",
"clap",
@@ -368,10 +374,10 @@ dependencies = [
[[package]]
name = "bichon-core"
version = "2.0.1-alpha.1"
version = "2.0.2"
dependencies = [
"async-imap",
"base64 0.22.1",
"base64 0.23.0",
"bichon-blob",
"bichon-memdb",
"blake3",
@@ -444,7 +450,7 @@ dependencies = [
[[package]]
name = "bichon-server"
version = "2.0.1-alpha.1"
version = "2.0.2"
dependencies = [
"bichon-core",
"bichon-smtp",
@@ -469,9 +475,9 @@ dependencies = [
[[package]]
name = "bichon-smtp"
version = "2.0.1-alpha.1"
version = "2.0.2"
dependencies = [
"base64 0.22.1",
"base64 0.23.0",
"bichon-core",
"lettre",
"rcgen",
@@ -1976,9 +1982,9 @@ checksum = "e9025058dae765dee5070ec375f591e2ba14638c63feff74f13805a72e523163"
[[package]]
name = "http"
version = "1.4.2"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425"
checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0"
dependencies = [
"bytes 1.12.0",
"itoa",
@@ -4928,9 +4934,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
[[package]]
name = "tokio"
version = "1.52.3"
version = "1.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe"
checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed"
dependencies = [
"bytes 1.12.0",
"libc",
@@ -5382,9 +5388,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]]
name = "uuid"
version = "1.23.4"
version = "1.24.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bf80a72845275afea99e7f2b434723d3bc7e38470fcd1c7ed39a599c73319a53"
checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239"
dependencies = [
"getrandom 0.4.2",
"js-sys",

View File

@@ -13,24 +13,24 @@ members = [
resolver = "2"
[workspace.package]
version = "2.0.1-alpha.1"
version = "2.0.2"
edition = "2021"
[workspace.dependencies]
chrono = "0.4.45"
clap = { version = "4.6.1", features = ["derive", "env"] }
clap = { version = "4.6", features = ["derive", "env"] }
bichon-memdb = { path = "crates/memdb" }
bichon-blob = { path = "crates/blob" }
itertools = "0.15.0"
ring = { version = "0.17.14", features = ["std"] }
serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.150"
tokio = { version = "1.52.3", features = ["full"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
tokio = { version = "1.53", features = ["full"] }
tracing = "0.1.44"
tracing-appender = "0.2.3"
tracing-subscriber = { version = "0.3.23", features = ["env-filter", "json"] }
base64 = "0.22.1"
snafu = "0.9.1"
base64 = "0.23"
snafu = "0.9"
reqwest = { version = "0.12.24", default-features = false, features = [
"json",
"stream",
@@ -40,10 +40,10 @@ reqwest = { version = "0.12.24", default-features = false, features = [
"socks",
] }
tokio-socks = "0.5.3"
http = "1.4.2"
http = "1.5"
regex = "1.13"
email_address = "0.2.9"
futures = "0.3.32"
futures = "0.3"
utf7-imap = "0.3.2"
mail-parser = { version = '0.11', features = ["serde"] }
# mail-send = "0.5.2"
@@ -58,16 +58,16 @@ sysinfo = "0.39"
num_cpus = "1.17.0"
rand = "0.10.2"
encoding_rs = "0.8.35"
webpki-roots = "1.0.8"
rustls = { version = "0.23.41", default-features = false, features = ["ring"] }
rustls-pki-types = "1.15.0"
webpki-roots = "1.0"
rustls = { version = "0.23", default-features = false, features = ["ring"] }
rustls-pki-types = "1.15"
tokio-io-timeout = "1.2.1"
semver = "1.0.28"
governor = "0.10.4"
lru = "0.18.1"
mime_guess = "2.0.5"
hex = "0.4.3"
time = { version = "0.3.53", features = [
time = { version = "0.3", features = [
"formatting",
"parsing",
"local-offset",
@@ -86,10 +86,10 @@ mail-send = "0.6.1"
rcgen = "0.14.8"
rustls-pemfile = "2.2.0"
blake3 = "1.8.5"
uuid = { version = "1.23.4", features = ["v4", "serde"] }
fjall = { version = "3.1.6", features = ["lz4", "metrics", "bytes_1"] }
uuid = { version = "1.24", features = ["v4", "serde"] }
fjall = { version = "3.1", features = ["lz4", "metrics", "bytes_1"] }
tracing-log = "0.2.0"
tokio-util = "0.7.18"
tokio-util = "0.7"
indicatif = "0.18.6"
[profile.release]

View File

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

View File

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

View File

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

View File

@@ -40,6 +40,10 @@ pub enum TriggerType {
Manual,
#[default]
Scheduled,
/// Full re-sync (UID SEARCH ALL) invoked explicitly, e.g. to repair a
/// mailbox whose incremental download was interrupted. Semantically a
/// manual trigger, tracked distinctly for diagnostics.
SyncFull,
}
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize)]
@@ -63,6 +67,64 @@ pub struct FolderProgress {
pub message: Option<String>,
}
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize)]
#[cfg_attr(feature = "web-api", derive(poem_openapi::Enum))]
pub enum GapFillStatus {
#[default]
Running,
Success,
Failed,
Cancelled,
}
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize)]
#[cfg_attr(feature = "web-api", derive(poem_openapi::Object))]
pub struct GapFillFolderStats {
pub downloaded: u64,
pub failed: u64,
pub candidate_count: u64,
/// Live progress hint (e.g. "IMAP server is slow...") shown while the
/// folder is being scanned; usually `None` once the folder is done.
pub message: Option<String>,
}
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize)]
#[cfg_attr(feature = "web-api", derive(poem_openapi::Object))]
pub struct GapFillRun {
pub started_at: i64,
pub finished_at: Option<i64>,
pub status: GapFillStatus,
/// Per-mailbox gap-fill outcome, keyed by mailbox name.
pub folders: BTreeMap<String, GapFillFolderStats>,
/// Total emails newly downloaded by gap-fill.
pub downloaded: u64,
/// Total emails that failed to download during gap-fill.
pub failed: u64,
}
/// Independent, repeatable gap-fill history for an account. Gap-fill is a
/// distinct operation from downloading (it can be run again and again until
/// `failed == 0`), so its runs are tracked separately from `DownloadState`
/// instead of being mixed into download sessions.
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize)]
#[cfg_attr(feature = "web-api", derive(poem_openapi::Object))]
pub struct GapFillState {
pub account_id: u64,
/// The gap-fill run currently in progress, if any.
pub active: Option<GapFillRun>,
/// Finished runs, most recent last.
pub history: Vec<GapFillRun>,
}
impl MemDbModel for GapFillState {
fn collection() -> &'static str {
"gap_fill_states"
}
fn key(&self) -> String {
self.account_id.to_string()
}
}
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize)]
#[cfg_attr(feature = "web-api", derive(poem_openapi::Object))]
pub struct DownloadSession {
@@ -189,6 +251,47 @@ impl DownloadState {
})
}
/// Moves a stale Running session into history as Cancelled.
///
/// A Running `active_session` that survives an Idle decision means the
/// previous run was interrupted without a clean shutdown (e.g. process
/// killed mid-download). Leaving it in place makes the UI show a phantom
/// "syncing" state even though nothing is downloading. Callers invoke this
/// only when no download is actually running for the account, so a
/// legitimately active session is never touched.
///
/// Returns `true` if a stale session was finalized (i.e. the previous sync
/// did not finish) and `false` otherwise.
pub fn finalize_stale_session(account_id: u64) -> BichonResult<bool> {
let stale = Self::get(account_id)?
.and_then(|s| s.active_session)
.map_or(false, |s| s.status == DownloadStatus::Running);
if stale {
Self::update_state(account_id, move |current| {
let mut updated = current.clone();
if updated
.active_session
.as_ref()
.map_or(false, |s| s.status == DownloadStatus::Running)
{
if let Some(mut session) = updated.active_session.take() {
session.status = DownloadStatus::Cancelled;
session.end_time = Some(utc_now!());
session.message = Some(
"Previous sync did not finish cleanly; marked as cancelled on startup."
.into(),
);
updated.history.push(session);
updated.last_finished_at = Some(utc_now!());
updated.active_session = None;
}
}
Ok(updated)
})?;
}
Ok(stale)
}
pub fn update_folder_progress(
account_id: u64,
folder_name: String,
@@ -220,6 +323,19 @@ impl DownloadState {
})
}
/// Touches only `current_folder` without rewriting folder progress. Lets
/// long-running IMAP operations (e.g. waiting on a slow server mid-batch)
/// keep the UI's "last activity" indicator fresh without spamming writes.
pub fn set_current_folder(account_id: u64, folder_name: String) -> BichonResult<()> {
Self::update_state(account_id, move |state| {
let mut updated = state.clone();
if let Some(ref mut session) = updated.active_session {
session.current_folder = Some(folder_name);
}
Ok(updated)
})
}
pub fn init_folder_details(account_id: u64, folders: Vec<String>) -> BichonResult<()> {
Self::update_state(account_id, move |state| {
let mut updated = state.clone();
@@ -263,6 +379,18 @@ impl DownloadState {
})
}
/// Appends/updates a free-form message on the active session without
/// changing its status. Used to record the gap-fill summary.
pub fn update_session_message(account_id: u64, message: String) -> BichonResult<()> {
Self::update_state(account_id, move |current| {
let mut updated = current.clone();
if let Some(ref mut session) = updated.active_session {
session.message = Some(message);
}
Ok(updated)
})
}
fn update_state(
account_id: u64,
updater: impl FnOnce(DownloadState) -> BichonResult<DownloadState> + Send + 'static,
@@ -281,3 +409,161 @@ impl DownloadState {
delete_impl::<DownloadState>(DB_MANAGER.db(), &account_id.to_string())
}
}
impl GapFillState {
pub fn get(account_id: u64) -> BichonResult<Option<GapFillState>> {
find_impl::<GapFillState>(DB_MANAGER.db(), &account_id.to_string())
}
/// Starts a new gap-fill run, moving any stale active run into history as
/// Cancelled. Creates the state record on first use.
pub fn start_run(account_id: u64) -> BichonResult<()> {
let now = utc_now!();
let run = GapFillRun {
started_at: now,
status: GapFillStatus::Running,
..Default::default()
};
if Self::get(account_id)?.is_none() {
let state = GapFillState {
account_id,
active: Some(run),
history: Vec::new(),
};
return upsert_impl(DB_MANAGER.db(), state);
}
Self::update_state(account_id, move |current| {
let mut updated = current.clone();
if let Some(mut old) = updated.active.take() {
if old.status == GapFillStatus::Running {
old.status = GapFillStatus::Cancelled;
old.finished_at = Some(utc_now!());
}
updated.history.push(old);
let keep = updated.history.len().saturating_sub(10);
if keep > 0 {
updated.history.drain(0..keep);
}
}
updated.active = Some(run);
Ok(updated)
})
}
/// Accumulates a per-folder outcome into the active run.
pub fn add_folder_result(
account_id: u64,
folder_name: String,
stats: GapFillFolderStats,
) -> BichonResult<()> {
Self::update_state(account_id, move |current| {
let mut updated = current.clone();
if let Some(ref mut run) = updated.active {
run.downloaded += stats.downloaded;
run.failed += stats.failed;
run.folders.insert(folder_name, stats);
}
Ok(updated)
})
}
/// Updates the live per-folder progress of the active run (used during a
/// gap-fill scan so the UI can show per-folder progress without waiting
/// for the folder to finish). `candidate_count` is the planned total,
/// `downloaded` the current count, `message` an optional live hint
/// (e.g. slow-server notice).
pub fn update_folder_progress(
account_id: u64,
folder_name: String,
candidate_count: u64,
downloaded: u64,
message: Option<String>,
) -> BichonResult<()> {
Self::update_state(account_id, move |current| {
let mut updated = current.clone();
if let Some(ref mut run) = updated.active {
let entry = run
.folders
.entry(folder_name.clone())
.or_insert(GapFillFolderStats {
downloaded: 0,
failed: 0,
candidate_count,
message: None,
});
entry.candidate_count = candidate_count;
entry.downloaded = downloaded;
entry.message = message;
}
Ok(updated)
})
}
/// Finalizes the active run: moves it to history with the given status and
/// totals. `failed`/`downloaded` are the authoritative accumulated values.
pub fn finish_run(
account_id: u64,
status: GapFillStatus,
downloaded: u64,
failed: u64,
) -> BichonResult<()> {
Self::update_state(account_id, move |current| {
let mut updated = current.clone();
if let Some(mut run) = updated.active.take() {
run.status = status;
run.finished_at = Some(utc_now!());
run.downloaded = downloaded;
run.failed = failed;
updated.history.push(run);
let keep = updated.history.len().saturating_sub(10);
if keep > 0 {
updated.history.drain(0..keep);
}
}
Ok(updated)
})
}
/// Moves a stale Running active run into history as Cancelled.
///
/// A Running `active` that survives a restart means the previous gap-fill
/// run was interrupted without finishing (process killed mid-scan). Leaving
/// it in place makes the UI show a phantom "Running" gap-fill. Callers
/// invoke this on startup, when no gap-fill is actually running.
///
/// Returns `true` if a stale run was finalized.
pub fn finalize_stale_run(account_id: u64) -> BichonResult<bool> {
let stale = Self::get(account_id)?
.and_then(|s| s.active)
.map_or(false, |r| r.status == GapFillStatus::Running);
if stale {
Self::update_state(account_id, move |current| {
let mut updated = current.clone();
if let Some(mut run) = updated.active.take() {
if run.status == GapFillStatus::Running {
run.status = GapFillStatus::Cancelled;
run.finished_at = Some(utc_now!());
updated.history.push(run);
let keep = updated.history.len().saturating_sub(10);
if keep > 0 {
updated.history.drain(0..keep);
}
} else {
updated.active = Some(run);
}
}
Ok(updated)
})?;
}
Ok(stale)
}
fn update_state(
account_id: u64,
updater: impl FnOnce(GapFillState) -> BichonResult<GapFillState> + Send + 'static,
) -> BichonResult<()> {
if Self::get(account_id)?.is_some() {
update_impl(DB_MANAGER.db(), &account_id.to_string(), updater)?;
}
Ok(())
}
}

View File

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

View File

@@ -53,6 +53,7 @@ pub async fn decide_next_download_task(
let should_start = match trigger_type {
TriggerType::Manual => true,
TriggerType::SyncFull => true,
TriggerType::Scheduled => {
let now = utc_now!();
let cooldown_ok = now - state.last_finished_at.unwrap_or(0) > 60 * 1000;
@@ -73,10 +74,14 @@ pub async fn decide_next_download_task(
DownloadState::start_new_session(account.id, trigger_type)?;
Ok(DownloadTask::TraceFetch)
} else {
// Nothing to download right now. A Running active_session at this point
// is a leftover from an interrupted run (a real download would have
// been blocked by the busy guard before reaching here), so mark it
// Cancelled instead of leaving the UI showing a phantom "syncing".
DownloadState::finalize_stale_session(account.id)?;
Ok(DownloadTask::Idle)
}
}
fn should_trigger_next_download(last_trigger_at: i64, sync_interval_min: i64) -> bool {
let now = utc_now!();
now - last_trigger_at > (sync_interval_min * 60 * 1000)

View File

@@ -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,
@@ -33,7 +33,8 @@ use crate::{
},
error::{code::ErrorCode, BichonResult},
imap::executor::{
compress_uid_list, generate_uid_sequence_hashset, ImapExecutor, DEFAULT_BATCH_SIZE,
compress_uid_list, generate_uid_sequence_hashset, slow_server_message, ImapExecutor,
DEFAULT_BATCH_SIZE,
},
store::tantivy::envelope::ENVELOPE_MANAGER,
},
@@ -44,7 +45,6 @@ use tracing::{debug, error, info, warn};
const MAX_NETWORK_RETRIES: u32 = 3;
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum FetchDirection {
Since,
@@ -163,13 +163,24 @@ pub async fn fetch_and_save_by_date(
&batch.0,
account.max_email_size_bytes,
token.clone(),
Some(&|cumulative, avg_secs, stall_secs| {
// Per-message progress: the current batch's cumulative count
// keeps the UI moving while a slow server trickles messages.
let _ = DownloadState::update_folder_progress(
account_id,
mailbox.name.clone(),
planned,
cumulative,
FolderStatus::Downloading,
slow_server_message(avg_secs, stall_secs),
);
Ok(())
}),
)
.await
{
Ok(processed) => break Ok(processed),
Err(e)
if retries < MAX_NETWORK_RETRIES && e.code() == ErrorCode::NetworkError =>
{
Ok((processed, _throttled)) => break Ok(processed),
Err(e) if retries < MAX_NETWORK_RETRIES && e.code() == ErrorCode::NetworkError => {
retries += 1;
warn!(
account_id,
@@ -183,22 +194,16 @@ pub async fn fetch_and_save_by_date(
match ImapExecutor::create_connection(account_id).await {
Ok(new_session) => {
session = new_session;
if let Err(e2) = session.examine(&mailbox.encoded_name()).await
{
let err_msg = format!(
"Re-examine failed after reconnect: {:#?}",
e2
);
DownloadState::append_session_error(
account_id,
err_msg,
)?;
if let Err(e2) = session.examine(&mailbox.encoded_name()).await {
let err_msg =
format!("Re-examine failed after reconnect: {:#?}", e2);
DownloadState::append_session_error(account_id, err_msg)?;
break Err(e);
}
tokio::time::sleep(Duration::from_secs(
1 << (retries - 1),
))
.await;
// Longer backoff than the original 1s/2s/4s: a
// throttling server needs time to recover.
let backoff = [5u64, 15, 30][(retries - 1) as usize];
tokio::time::sleep(Duration::from_secs(backoff)).await;
continue;
}
Err(e2) => {
@@ -254,6 +259,11 @@ pub async fn fetch_and_save_by_date(
/// Fetches all messages from a mailbox.
/// Returns `Ok(Some(max_uid))` with the highest UID stored, or `Ok(None)` if empty.
///
/// The mailbox is enumerated via `UID SEARCH ALL` first, then downloaded in UID
/// batches. Unlike sequence-number paging, UIDs stay stable while the download
/// runs (new arrivals only get larger UIDs), so no message is silently skipped
/// when the server changes mid-download.
pub async fn fetch_and_save_full_mailbox(
account: &AccountModel,
mailbox: &MailBox,
@@ -279,41 +289,57 @@ pub async fn fetch_and_save_full_mailbox(
}
};
let total = match session.examine(&mailbox.encoded_name()).await {
Ok(mailbox) => mailbox.exists as u64,
Err(e) => {
let err_msg = format!("Failed to examine folder [{}]: {:#?}", mailbox.name, e);
DownloadState::update_folder_progress(
account_id,
mailbox.name.clone(),
mailbox.exists as u64,
0,
FolderStatus::Failed,
Some(err_msg.clone()),
)?;
let uid_list =
match ImapExecutor::uid_search_all_mailbox(&mut session, &mailbox.encoded_name()).await {
Ok(list) => list,
Err(e) => {
let err_msg = format!("UID SEARCH failed in [{}]: {:#?}", mailbox.name, e);
DownloadState::update_folder_progress(
account_id,
mailbox.name.clone(),
0,
0,
FolderStatus::Failed,
Some(err_msg.clone()),
)?;
DownloadState::append_session_error(account_id, err_msg)?;
session.logout().await.ok();
return Err(e);
}
};
DownloadState::append_session_error(account_id, err_msg)?;
session.logout().await.ok();
return Err(raise_error!(
format!("{:#?}", e),
ErrorCode::ImapCommandFailed
));
}
};
let planned = uid_list.len() as u64;
if planned == 0 {
info!(
"Mailbox '{}' is empty, no emails to download.",
mailbox.name
);
DownloadState::update_folder_progress(
account_id,
mailbox.name.clone(),
0,
0,
FolderStatus::Success,
None,
)?;
session.logout().await.ok();
return Ok(None);
}
let page_size = account.download_batch_size.unwrap_or(DEFAULT_BATCH_SIZE);
let total_batches = total.div_ceil(page_size as u64);
let max_uid = *uid_list.last().unwrap();
let page_size = account.download_batch_size.unwrap_or(DEFAULT_BATCH_SIZE) as usize;
let uid_batches = generate_uid_sequence_hashset(uid_list, page_size);
let total_batches = uid_batches.len();
info!(
"Starting full mailbox download for '{}', total={}, batches={}",
mailbox.name, total, total_batches
"Starting full mailbox download for '{}', uids={}, batches={}",
mailbox.name, planned, total_batches
);
let mut current_processed = 0u64;
let mut has_error_or_cancel = false;
let mut max_uid: Option<u32> = None;
for page in 1..=total_batches {
for (index, batch) in uid_batches.into_iter().enumerate() {
if token.is_cancelled() {
DownloadState::update_session_status(
account_id,
@@ -323,7 +349,7 @@ pub async fn fetch_and_save_full_mailbox(
DownloadState::update_folder_progress(
account_id,
mailbox.name.clone(),
total,
planned,
current_processed,
FolderStatus::Cancelled,
None,
@@ -332,48 +358,66 @@ pub async fn fetch_and_save_full_mailbox(
break;
}
// Heartbeat: keeps `current_folder` fresh so the web UI can show
// "waiting for server" while a slow IMAP server is mid-batch.
DownloadState::set_current_folder(account_id, mailbox.name.clone())?;
// Heartbeat: keeps `current_folder` fresh so the web UI can show
// "waiting for server" while a slow IMAP server is mid-batch.
DownloadState::set_current_folder(account_id, mailbox.name.clone())?;
let mut retries = 0u32;
let batch_result = loop {
match ImapExecutor::batch_retrieve_emails(
match ImapExecutor::uid_batch_retrieve_emails(
&mut session,
account_id,
mailbox_id,
total,
page as u64,
page_size as u64,
&mailbox.encoded_name(),
&batch.0,
account.max_email_size_bytes,
token.clone(),
&mut max_uid,
Some(&|cumulative, avg_secs, stall_secs| {
// Per-message progress: the current batch's cumulative count
// keeps the UI moving while a slow server trickles messages.
let _ = DownloadState::update_folder_progress(
account_id,
mailbox.name.clone(),
planned,
cumulative,
FolderStatus::Downloading,
slow_server_message(avg_secs, stall_secs),
);
Ok(())
}),
)
.await
{
Ok(count) => break Ok(count),
Err(e)
if retries < MAX_NETWORK_RETRIES && e.code() == ErrorCode::NetworkError =>
{
Ok((count, _throttled)) => break Ok(count),
Err(e) if retries < MAX_NETWORK_RETRIES && e.code() == ErrorCode::NetworkError => {
retries += 1;
warn!(
account_id,
mailbox = mailbox.name,
page,
index,
retries,
"Network error on batch, reconnecting ({}/{})",
retries,
MAX_NETWORK_RETRIES
);
// Refresh the heartbeat after a reconnect too.
let _ = DownloadState::set_current_folder(account_id, mailbox.name.clone());
match ImapExecutor::create_connection(account_id).await {
Ok(new_session) => {
session = new_session;
if let Err(e2) = session.examine(&mailbox.encoded_name()).await {
let err_msg = format!(
"Re-examine failed after reconnect: {:#?}",
e2
);
let err_msg =
format!("Re-examine failed after reconnect: {:#?}", e2);
DownloadState::append_session_error(account_id, err_msg)?;
break Err(e);
}
tokio::time::sleep(Duration::from_secs(1 << (retries - 1))).await;
// Longer backoff than the original 1s/2s/4s: a
// throttling server needs time to recover.
let backoff = [5u64, 15, 30][(retries - 1) as usize];
tokio::time::sleep(Duration::from_secs(backoff)).await;
continue;
}
Err(e2) => {
@@ -391,19 +435,19 @@ pub async fn fetch_and_save_full_mailbox(
DownloadState::update_folder_progress(
account_id,
mailbox.name.clone(),
total,
planned,
current_processed,
FolderStatus::Downloading,
None,
)?;
}
Err(e) => {
let err_msg = format!("Batch {} failed: {:#?}", page, e);
let err_msg = format!("Batch {} failed: {:#?}", index, e);
DownloadState::append_session_error(account_id, err_msg.clone())?;
DownloadState::update_folder_progress(
account_id,
mailbox.name.clone(),
total,
planned,
current_processed,
FolderStatus::Failed,
Some(err_msg),
@@ -411,21 +455,21 @@ pub async fn fetch_and_save_full_mailbox(
has_error_or_cancel = true;
break;
}
};
}
}
if !has_error_or_cancel {
DownloadState::update_folder_progress(
account_id,
mailbox.name.clone(),
total,
planned,
current_processed,
FolderStatus::Success,
None,
)?;
}
session.logout().await.ok();
Ok(max_uid)
Ok(max_uid.into())
}
/// Generates a synthetic UIDVALIDITY for IMAP servers that don't provide it.
@@ -490,15 +534,13 @@ where
Ok(None) => {
warn!(
attempt = attempt + 1,
max_retries,
"STATUS returned no UIDVALIDITY"
max_retries, "STATUS returned no UIDVALIDITY"
);
}
Err(e) => {
warn!(
attempt = attempt + 1,
max_retries,
"UIDVALIDITY fetch attempt failed: {:#?}", e
max_retries, "UIDVALIDITY fetch attempt failed: {:#?}", e
);
}
}
@@ -634,9 +676,7 @@ async fn reconcile_uid_validity_change(
.await
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
let batch_size = account
.download_batch_size
.unwrap_or(DEFAULT_BATCH_SIZE) as usize;
let batch_size = account.download_batch_size.unwrap_or(DEFAULT_BATCH_SIZE) as usize;
let batches = generate_uid_sequence_hashset(missing_uids, batch_size);
let mut downloaded = 0u64;
@@ -661,10 +701,23 @@ async fn reconcile_uid_validity_change(
&batch.0,
account.max_email_size_bytes,
token.clone(),
Some(&|cumulative, avg_secs, stall_secs| {
// Per-message progress: the current batch's cumulative count
// keeps the UI moving while a slow server trickles messages.
let _ = DownloadState::update_folder_progress(
account_id,
remote_mailbox.name.clone(),
planned,
cumulative,
FolderStatus::Downloading,
slow_server_message(avg_secs, stall_secs),
);
Ok(())
}),
)
.await
{
Ok(processed) => {
Ok((processed, _throttled)) => {
downloaded += processed;
DownloadState::update_folder_progress(
account_id,
@@ -791,24 +844,31 @@ pub async fn reconcile_mailboxes(
account_id, local_mailbox.name, &local_mailbox.uid_validity, &remote_uid_validity
);
reconcile_uid_validity_change(
account,
local_mailbox,
remote_mailbox,
token.clone(),
)
.await?
reconcile_uid_validity_change(account, local_mailbox, remote_mailbox, token.clone())
.await?
} else {
perform_incremental_sync(account, local_mailbox, remote_mailbox, token.clone())
.await?
};
info!(
account_id,
mailbox = %remote_mailbox.name,
local_highest = local_mailbox.highest_uid,
new_highest = new_highest_uid,
"reconcile: computed highest_uid for mailbox"
);
let mut updated = remote_mailbox.clone();
updated.highest_uid = new_highest_uid;
// Update uid_validity with the resolved value (either from server or synthetic)
if updated.uid_validity.is_none() {
updated.uid_validity = Some(remote_uid_validity);
}
info!(
account_id,
mailbox = %updated.name,
highest_uid = updated.highest_uid,
"reconcile: persisting mailbox state"
);
mailboxes_to_update.push(updated);
}
//The metadata of this mailbox must only be updated after a successful synchronization;
@@ -925,32 +985,27 @@ async fn perform_incremental_sync(
// query once (backward compatibility with pre-existing databases).
let start_uid = match local_mailbox.highest_uid {
Some(uid) => {
tracing::info!(
"[account {}][mailbox {}] perform_incremental_sync: stored highest_uid={}, remote.exists={}",
account.id,
local_mailbox.name,
uid,
remote_mailbox.exists
info!(
account_id = account.id,
mailbox = %local_mailbox.name,
highest_uid = uid,
"incremental: stored highest_uid"
);
uid as u64 + 1
}
None => {
let local_max_uid =
ENVELOPE_MANAGER.get_max_uid(account.id, local_mailbox.id)?;
tracing::info!(
"[account {}][mailbox {}] perform_incremental_sync: highest_uid unset, Tantivy max_uid={:?}, remote.exists={}",
account.id,
local_mailbox.name,
local_max_uid,
remote_mailbox.exists
warn!(
account_id = account.id,
mailbox = %local_mailbox.name,
"incremental: no stored highest_uid, falling back to Tantivy get_max_uid"
);
let local_max_uid = ENVELOPE_MANAGER.get_max_uid(account.id, local_mailbox.id)?;
match local_max_uid {
Some(uid) => uid + 1,
None => {
info!(
"No maximum UID found in index for mailbox, assuming local storage is missing."
);
let result = match &account.date_since {
Some(date_since) => {
fetch_and_save_by_date(
@@ -974,10 +1029,8 @@ async fn perform_incremental_sync(
.await?
}
None => {
fetch_and_save_full_mailbox(
account, remote_mailbox, token,
)
.await?
fetch_and_save_full_mailbox(account, remote_mailbox, token)
.await?
}
},
};
@@ -993,6 +1046,19 @@ async fn perform_incremental_sync(
.as_ref()
.map(|r| r.calculate_date())
.transpose()?;
if start_uid == 1 {
// No stored highest_uid and no indexed messages: fall back to a
// full mailbox download. Tell the UI up-front so it doesn't show
// a stale "Pending" while the (possibly large) mailbox streams.
let _ = DownloadState::update_folder_progress(
account.id,
remote_mailbox.name.clone(),
0,
0,
FolderStatus::Downloading,
Some("Full mailbox download".into()),
)?;
}
let new_max_uid = ImapExecutor::fetch_new_mail(
&mut session,
@@ -1034,7 +1100,10 @@ mod tests {
fn test_generate_synthetic_uidvalidity_different_mailboxes() {
let inbox = generate_synthetic_uidvalidity("INBOX");
let sent = generate_synthetic_uidvalidity("Sent");
assert_ne!(inbox, sent, "different mailboxes should have different uid_validity");
assert_ne!(
inbox, sent,
"different mailboxes should have different uid_validity"
);
}
#[test]
@@ -1070,10 +1139,8 @@ mod tests {
// Ensure a rustls crypto provider is installed (ring).
// May already be installed by production code; ignore duplicate.
rustls::crypto::CryptoProvider::install_default(
rustls::crypto::ring::default_provider(),
)
.ok();
rustls::crypto::CryptoProvider::install_default(rustls::crypto::ring::default_provider())
.ok();
let tcp = TcpStream::connect((host, port))
.await
@@ -1084,8 +1151,8 @@ mod tests {
let timeout_stream = TimeoutStream::new(tcp);
let pinned = Box::pin(timeout_stream);
let server_name = ServerName::try_from(host.to_owned())
.map_err(|e| format!("Invalid hostname: {e}"))?;
let server_name =
ServerName::try_from(host.to_owned()).map_err(|e| format!("Invalid hostname: {e}"))?;
let config = ClientConfig::builder()
.with_root_certificates(rustls::RootCertStore {
@@ -1208,11 +1275,7 @@ mod tests {
session.logout().await.ok();
println!(
"Call {}: UIDVALIDITY = {:?}",
i + 1,
status.uid_validity
);
println!("Call {}: UIDVALIDITY = {:?}", i + 1, status.uid_validity);
results.borrow_mut().push(status.uid_validity);
}
@@ -1275,13 +1338,10 @@ mod tests {
let sample: Vec<u32> = all_uids.into_iter().take(5).collect();
let uid_set = compress_uid_list(sample.clone());
let result = ImapExecutor::fetch_uid_metadata(
&mut session,
&uid_set,
CancellationToken::new(),
)
.await
.expect("fetch_uid_metadata should succeed");
let result =
ImapExecutor::fetch_uid_metadata(&mut session, &uid_set, CancellationToken::new())
.await
.expect("fetch_uid_metadata should succeed");
session.logout().await.ok();
@@ -1329,8 +1389,7 @@ mod tests {
#[tokio::test]
async fn test_retry_first_attempt_succeeds() {
let result = fetch_uid_validity_with_retry_inner(3, mock_results(vec![Ok(Some(42))]))
.await;
let result = fetch_uid_validity_with_retry_inner(3, mock_results(vec![Ok(Some(42))])).await;
assert_eq!(result.unwrap(), Some(42));
}
@@ -1392,13 +1451,7 @@ mod tests {
// max_retries=5, success on 5th attempt
let result = fetch_uid_validity_with_retry_inner(
5,
mock_results(vec![
Ok(None),
Ok(None),
Ok(None),
Ok(None),
Ok(Some(5)),
]),
mock_results(vec![Ok(None), Ok(None), Ok(None), Ok(None), Ok(Some(5))]),
)
.await;
assert_eq!(result.unwrap(), Some(5));
@@ -1418,11 +1471,7 @@ mod tests {
#[tokio::test]
async fn test_retry_max_retries_zero() {
// max_retries=0 means no attempts at all
let result = fetch_uid_validity_with_retry_inner(
0,
mock_results(vec![Ok(Some(42))]),
)
.await;
let result = fetch_uid_validity_with_retry_inner(0, mock_results(vec![Ok(Some(42))])).await;
assert_eq!(result.unwrap(), None);
}
@@ -1431,8 +1480,8 @@ mod tests {
// ============================================================
use crate::imap::mock_server::{
examine_response, uid_fetch_metadata_response, uid_fetch_rfc822_response,
minimal_eml, MockImapServer, MockImapServerHandle,
examine_response, minimal_eml, uid_fetch_metadata_response, uid_fetch_rfc822_response,
MockImapServer, MockImapServerHandle,
};
/// Build an `async_imap::Session` connected to the mock server,
@@ -1453,9 +1502,11 @@ mod tests {
client.read_response().await.unwrap();
// Login
let mut session = client.login("user", "pass").await.map_err(|(e, _)| {
panic!("Login failed: {e:?}")
}).unwrap();
let mut session = client
.login("user", "pass")
.await
.map_err(|(e, _)| panic!("Login failed: {e:?}"))
.unwrap();
// Examine
session.examine("INBOX").await.unwrap();
@@ -1468,37 +1519,28 @@ mod tests {
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_metadata_response(&[
(1, "<msg-a@test.com>"),
(2, "<msg-b@test.com>"),
(3, "<msg-c@test.com>"),
]))
.respond(
"UID FETCH",
uid_fetch_metadata_response(&[
(1, "<msg-a@test.com>"),
(2, "<msg-b@test.com>"),
(3, "<msg-c@test.com>"),
]),
)
.start()
.await;
let mut session = mock_session(&handle).await;
let result = ImapExecutor::fetch_uid_metadata(
&mut session,
"1:3",
CancellationToken::new(),
)
.await
.unwrap();
let result =
ImapExecutor::fetch_uid_metadata(&mut session, "1:3", CancellationToken::new())
.await
.unwrap();
assert_eq!(result.len(), 3);
assert_eq!(
result.get(&1).unwrap().as_deref(),
Some("msg-a@test.com")
);
assert_eq!(
result.get(&2).unwrap().as_deref(),
Some("msg-b@test.com")
);
assert_eq!(
result.get(&3).unwrap().as_deref(),
Some("msg-c@test.com")
);
assert_eq!(result.get(&1).unwrap().as_deref(), Some("msg-a@test.com"));
assert_eq!(result.get(&2).unwrap().as_deref(), Some("msg-b@test.com"));
assert_eq!(result.get(&3).unwrap().as_deref(), Some("msg-c@test.com"));
session.logout().await.ok();
}
@@ -1523,10 +1565,7 @@ mod tests {
let mut session = mock_session(&handle).await;
let uids: Vec<u32> = {
let mut stream = session
.uid_fetch("1:2", "(UID FLAGS)")
.await
.unwrap();
let mut stream = session.uid_fetch("1:2", "(UID FLAGS)").await.unwrap();
use futures::TryStreamExt;
let mut uids = Vec::new();
@@ -1557,10 +1596,7 @@ mod tests {
let mut session = mock_session(&handle).await;
let bodies: Vec<(u32, Vec<u8>)> = {
let mut stream = session
.uid_fetch("1:1", "(UID BODY[])")
.await
.unwrap();
let mut stream = session.uid_fetch("1:1", "(UID BODY[])").await.unwrap();
use futures::TryStreamExt;
let mut bodies = Vec::new();
@@ -1591,18 +1627,12 @@ mod tests {
let mut session = mock_session(&handle).await;
let result = ImapExecutor::fetch_uid_metadata(
&mut session,
"1:*",
CancellationToken::new(),
)
.await
.unwrap();
let result =
ImapExecutor::fetch_uid_metadata(&mut session, "1:*", CancellationToken::new())
.await
.unwrap();
assert!(
result.is_empty(),
"empty mailbox should return empty map"
);
assert!(result.is_empty(), "empty mailbox should return empty map");
session.logout().await.ok();
}
@@ -1610,8 +1640,7 @@ mod tests {
#[tokio::test]
async fn fetch_uid_metadata_missing_message_id() {
// One entry has a Message-ID, the other has no header at all.
let header_with_msgid =
"From: sender@example.com\r\n\
let header_with_msgid = "From: sender@example.com\r\n\
Date: Thu, 01 Jan 2025 00:00:00 +0000\r\n\
Message-ID: <ok@test.com>\r\n\r\n";
let header_without_msgid = "\r\n";
@@ -1637,19 +1666,13 @@ mod tests {
let mut session = mock_session(&handle).await;
let result = ImapExecutor::fetch_uid_metadata(
&mut session,
"1:2",
CancellationToken::new(),
)
.await
.unwrap();
let result =
ImapExecutor::fetch_uid_metadata(&mut session, "1:2", CancellationToken::new())
.await
.unwrap();
assert_eq!(result.len(), 2);
assert_eq!(
result.get(&1).unwrap().as_deref(),
Some("ok@test.com")
);
assert_eq!(result.get(&1).unwrap().as_deref(), Some("ok@test.com"));
// UID 2 has no Message-ID header → None
assert_eq!(result.get(&2).unwrap().as_deref(), None);

View File

@@ -0,0 +1,472 @@
//
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use std::collections::{HashMap, HashSet};
use tracing::{info, warn};
use crate::error::code::ErrorCode;
use crate::raise_error;
use crate::{
account::{
migration::AccountModel,
state::{DownloadState, GapFillFolderStats, GapFillState},
},
archive::imap::mailbox::MailBox,
error::BichonResult,
imap::executor::{compress_uid_list, ImapExecutor, DEFAULT_BATCH_SIZE},
store::tantivy::envelope::EnvelopeSnapshot,
};
/// Number of times a batch download is retried with a fresh connection before
/// it is counted as failed (mirrors the incremental sync path).
const MAX_NETWORK_RETRIES: u32 = 3;
/// Lightweight header metadata for one remote message, fetched via
/// `FETCH (UID RFC822.SIZE INTERNALDATE BODY.PEEK[HEADER.FIELDS (MESSAGE-ID)])`.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct RemoteHeader {
pub uid: u32,
pub message_id: Option<String>,
pub size: u64,
/// Epoch millis (internal date).
pub internal_date: i64,
}
/// Which remote uids are missing locally. A remote message is "present" if its
/// message-id exists locally. The (size, internal_date) fingerprint is a
/// fallback for every remote message — not only those without a message-id —
/// because some paths store a different message-id locally than the remote
/// header carries (e.g. the SMTP path generates a random one), and servers
/// like Zoho/163 reuse the same message-id across different messages. A
/// duplicated local message-id still counts as present: re-downloading would
/// be deduplicated away anyway, so it can never repair the duplication.
pub fn compute_missing_uids(remote: &[RemoteHeader], local: &[EnvelopeSnapshot]) -> Vec<u32> {
let mut local_by_msg_id: HashMap<&str, usize> = HashMap::new();
let mut local_by_fingerprint: HashSet<(u64, i64)> = HashSet::new();
for snap in local {
if !snap.message_id.is_empty() {
*local_by_msg_id.entry(snap.message_id.as_str()).or_insert(0) += 1;
}
local_by_fingerprint.insert((snap.size, snap.internal_date));
}
let mut missing = Vec::new();
for header in remote {
let present = match &header.message_id {
Some(msg_id) => local_by_msg_id
.get(msg_id.as_str())
.is_some_and(|&c| c > 0),
None => false,
};
if !present {
// Fingerprint fallback for every remote message, not just those
// without a message-id: the remote message-id may not exist
// locally even though the message is already stored (random
// synthetic ids on the SMTP path).
let fp_present = local_by_fingerprint.contains(&(header.size, header.internal_date));
if !fp_present {
missing.push(header.uid);
}
}
}
missing
}
/// Runs the gap-fill phase for one mailbox: enumerates every remote UID,
/// diffs against local envelopes, downloads the missing ones and returns the
/// per-folder outcome. Handles per-batch network errors by counting them as
/// failed (retryable on a later gap-fill run) instead of aborting the phase.
pub async fn gap_fill_mailbox(
account: &AccountModel,
local_mailbox: &MailBox,
remote_mailbox: &MailBox,
token: tokio_util::sync::CancellationToken,
) -> BichonResult<GapFillFolderStats> {
let account_id = account.id;
let mut stats = GapFillFolderStats::default();
let mut session = ImapExecutor::create_connection(account_id).await?;
session
.examine(&remote_mailbox.encoded_name())
.await
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
// Phase 1: enumerate every remote UID. A huge mailbox can make the server
// take a while to answer `UID SEARCH ALL`; keep the UI informed instead of
// appearing stuck (the socket read timeout is the final backstop).
let search_started = std::time::Instant::now();
let results = loop {
match tokio::time::timeout(std::time::Duration::from_secs(5), session.uid_search("ALL"))
.await
{
Ok(res) => {
break res
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
}
Err(_) => {
let stall = search_started.elapsed().as_secs_f64();
if token.is_cancelled() {
session.logout().await.ok();
return Ok(stats);
}
let _ = GapFillState::update_folder_progress(
account_id,
remote_mailbox.name.clone(),
0,
0,
crate::imap::executor::slow_server_message(None, Some(stall)),
);
tracing::warn!(
account_id,
mailbox = %remote_mailbox.name,
stall_secs = format!("{:.0}", stall),
"gap-fill: UID SEARCH ALL taking long, still waiting"
);
}
}
};
let mut remote_uids: Vec<u32> = results.into_iter().collect();
remote_uids.sort();
if remote_uids.is_empty() {
session.logout().await.ok();
return Ok(stats);
}
// Phase 2: fetch header metadata for all remote uids in batches.
// A failed batch counts its uids as failed (they cannot be diffed) but
// does not abort the phase. A cancellation, however, leaves the header
// list incomplete so the diff would be unreliable — return immediately.
let mut remote_headers: Vec<RemoteHeader> = Vec::with_capacity(remote_uids.len());
let batch_size = account.download_batch_size.unwrap_or(DEFAULT_BATCH_SIZE) as usize;
let mut cancelled = false;
// Progress reported so far across all header batches, so the UI can show
// enumeration progress (and slow-server hints) while a huge mailbox is
// being scanned.
let headers_fetched = std::sync::Mutex::new(0u64);
for chunk in remote_uids.chunks(batch_size) {
if token.is_cancelled() {
cancelled = true;
break;
}
let seq_set = compress_uid_list(chunk.to_vec());
match ImapExecutor::fetch_uid_headers(
&mut session,
&seq_set,
token.clone(),
Some(&|count, stall_secs| {
*headers_fetched.lock().unwrap() = count;
GapFillState::update_folder_progress(
account_id,
remote_mailbox.name.clone(),
remote_uids.len() as u64,
count,
crate::imap::executor::slow_server_message(None, stall_secs),
)
}),
)
.await
{
Ok(headers) => {
*headers_fetched.lock().unwrap() += headers.len() as u64;
remote_headers.extend(headers);
}
Err(e) => {
// Count the whole chunk as failed; the user can re-run
// gap-fill to retry. Do not abort the phase.
stats.failed += chunk.len() as u64;
let err_msg = format!("Gap-fill header batch failed: {:#?}", e);
warn!(account_id, mailbox = remote_mailbox.name, "{}", err_msg);
let _ = DownloadState::append_session_error(account_id, err_msg);
}
}
}
if cancelled {
session.logout().await.ok();
GapFillState::update_folder_progress(account_id, remote_mailbox.name.clone(), 0, 0, None)?;
return Ok(stats);
}
remote_headers.sort_by_key(|h| h.uid);
session.logout().await.ok();
// Phase 3: local snapshot
let local_snapshots = crate::store::tantivy::envelope::ENVELOPE_MANAGER
.get_envelope_snapshots_for_mailbox(account_id, local_mailbox.id)?;
// Phase 4: diff
let missing_uids = compute_missing_uids(&remote_headers, &local_snapshots);
stats.candidate_count = missing_uids.len() as u64;
if missing_uids.is_empty() {
info!(
account_id,
mailbox = remote_mailbox.name,
"Gap-fill: no missing emails"
);
GapFillState::update_folder_progress(account_id, remote_mailbox.name.clone(), 0, 0, None)?;
return Ok(stats);
}
// Phase 5: download missing in batches
let planned = missing_uids.len() as u64;
GapFillState::update_folder_progress(
account_id,
remote_mailbox.name.clone(),
planned,
0,
None,
)?;
let mut session2 = ImapExecutor::create_connection(account_id).await?;
session2
.examine(&remote_mailbox.encoded_name())
.await
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
let batches =
crate::imap::executor::generate_uid_sequence_hashset(missing_uids.clone(), batch_size);
let mut downloaded = 0u64;
let mut failed = 0u64;
let mut cancelled = false;
for (index, batch) in batches.into_iter().enumerate() {
if token.is_cancelled() {
cancelled = true;
break;
}
// A slow server can stall a batch past the socket read timeout, same
// as in the incremental path. Retry such batches on a fresh connection
// instead of counting them as failed outright.
let mut retries = 0u32;
// Tracks the last cumulative count the progress callback reported. When
// a batch fails mid-stream the executor reports the already-stored
// count one final time before returning the error, so this is the
// number of emails of this batch that actually made it to disk.
let last_reported = std::sync::Mutex::new(0u64);
let batch_result = loop {
match ImapExecutor::uid_batch_retrieve_emails(
&mut session2,
account_id,
remote_mailbox.id,
&batch.0,
account.max_email_size_bytes,
token.clone(),
Some(&|cumulative, avg_secs, stall_secs| {
*last_reported.lock().unwrap() = cumulative;
let _ = GapFillState::update_folder_progress(
account_id,
remote_mailbox.name.clone(),
planned,
downloaded + cumulative,
crate::imap::executor::slow_server_message(avg_secs, stall_secs),
);
Ok(())
}),
)
.await
{
Ok((processed, _throttled)) => break Ok(processed),
Err(e) if retries < MAX_NETWORK_RETRIES && e.code() == ErrorCode::NetworkError => {
retries += 1;
warn!(
account_id,
mailbox = remote_mailbox.name,
index,
retries,
"Gap-fill: network error on batch, reconnecting ({}/{})",
retries,
MAX_NETWORK_RETRIES
);
match ImapExecutor::create_connection(account_id).await {
Ok(new_session) => {
session2 = new_session;
if let Err(e2) = session2.examine(&remote_mailbox.encoded_name()).await
{
let err_msg = format!(
"Gap-fill: re-examine failed after reconnect: {:#?}",
e2
);
DownloadState::append_session_error(account_id, err_msg)?;
break Err(e);
}
// Longer backoff than the original 1s/2s/4s: a
// throttling server needs time to recover.
let backoff = [5u64, 15, 30][(retries - 1) as usize];
warn!(
account_id,
mailbox = remote_mailbox.name,
"Gap-fill: backing off {}s before retrying batch",
backoff
);
tokio::time::sleep(std::time::Duration::from_secs(backoff)).await;
continue;
}
Err(e2) => {
tracing::error!(account_id, "Gap-fill: reconnection failed: {:#?}", e2);
break Err(e);
}
}
}
Err(e) => break Err(e),
}
};
match batch_result {
Ok(processed) => {
downloaded += processed;
GapFillState::update_folder_progress(
account_id,
remote_mailbox.name.clone(),
planned,
downloaded,
None,
)?;
}
Err(e) => {
// The batch may have partially succeeded: emails already stored
// before the failure are counted as downloaded, only the rest
// of the batch is failed. The user can re-run gap-fill to
// retry the remainder; dedup makes re-downloading the stored
// ones harmless. Do not abort the phase.
let processed = *last_reported.lock().unwrap();
downloaded += processed;
let remaining = batch.1.saturating_sub(processed);
failed += remaining;
let err_msg = format!(
"Gap-fill batch {} failed after {} processed: {:#?}",
index, processed, e
);
warn!(account_id, mailbox = remote_mailbox.name, "{}", err_msg);
let _ = DownloadState::append_session_error(account_id, err_msg);
}
}
}
session2.logout().await.ok();
stats.downloaded = downloaded;
// Accumulate rather than overwrite: phase 2 (header batch) failures already
// counted into stats.failed and must survive alongside phase 5 failures.
stats.failed += failed;
// Final progress write into the independent gap-fill state (the folder is
// done; the outcome lands in GapFillRun.folders via add_folder_result).
GapFillState::update_folder_progress(
account_id,
remote_mailbox.name.clone(),
planned,
downloaded,
None,
)?;
// Advance the mailbox's highest_uid so subsequent incremental syncs
// start after the newly downloaded messages. Only do this on a complete,
// uncancelled run where every planned message was downloaded and nothing
// failed (phase-2 header batch failures leave uids outside `planned` that
// must still be picked up by a later gap-fill run).
if !cancelled && downloaded == planned && stats.failed == 0 {
if let Some(max_uid) = missing_uids.last().copied() {
let mut updated = remote_mailbox.clone();
updated.highest_uid = Some(max_uid.max(local_mailbox.highest_uid.unwrap_or(0)));
crate::archive::imap::mailbox::MailBox::batch_upsert(&[updated])?;
}
}
Ok(stats)
}
#[cfg(test)]
mod test {
use super::*;
fn rh(uid: u32, message_id: Option<&str>, size: u64, internal_date: i64) -> RemoteHeader {
RemoteHeader {
uid,
message_id: message_id.map(|s| s.to_string()),
size,
internal_date,
}
}
fn snap(message_id: &str, uid: u64, size: u64, internal_date: i64) -> EnvelopeSnapshot {
EnvelopeSnapshot {
message_id: message_id.to_string(),
uid,
size,
internal_date,
//subject: String::new(),
}
}
#[test]
fn compute_missing_uids_message_id_diff() {
let remote = vec![
rh(1, Some("a"), 10, 1000),
rh(2, Some("b"), 20, 2000),
rh(3, Some("c"), 30, 3000),
];
let local = vec![snap("a", 1, 10, 1000), snap("c", 3, 30, 3000)];
let missing = compute_missing_uids(&remote, &local);
assert_eq!(missing, vec![2]);
}
#[test]
fn compute_missing_uids_fingerprint_fallback() {
let remote = vec![rh(1, None, 10, 1000), rh(2, None, 20, 2000)];
let local = vec![snap("generated-x", 1, 10, 1000)];
let missing = compute_missing_uids(&remote, &local);
assert_eq!(missing, vec![2]);
}
#[test]
fn compute_missing_uids_remote_duplicates_all_present() {
let remote = vec![rh(1, Some("dup"), 10, 1000), rh(2, Some("dup"), 10, 1000)];
let local = vec![snap("dup", 1, 10, 1000)];
let missing = compute_missing_uids(&remote, &local);
assert!(missing.is_empty());
}
#[test]
fn compute_missing_uids_local_duplicate_not_re_downloaded() {
// A message-id appearing more than once locally is NOT a reason to
// re-download: servers (Zoho, 163) legitimately reuse message-ids
// across different messages, and re-downloading would be deduplicated
// away anyway, so it can never repair the duplication.
let remote = vec![rh(1, Some("dup"), 10, 1000), rh(2, Some("dup"), 10, 1000)];
let local = vec![snap("dup", 1, 10, 1000), snap("dup", 2, 10, 1000)];
let missing = compute_missing_uids(&remote, &local);
assert!(missing.is_empty());
}
#[test]
fn compute_missing_uids_msgid_mismatch_but_fingerprint_hit() {
// The remote message-id does not exist locally (e.g. a different
// message-id was stored by the SMTP path) but the fingerprint matches:
// the message is already stored and must NOT be re-downloaded.
let remote = vec![rh(1, Some("remote-id@x.com"), 10, 1000)];
let local = vec![snap("generated-random-id", 1, 10, 1000)];
let missing = compute_missing_uids(&remote, &local);
assert!(missing.is_empty());
}
#[test]
fn compute_missing_uids_msgid_mismatch_and_fingerprint_miss() {
let remote = vec![
rh(1, Some("remote-id@x.com"), 10, 1000),
rh(2, Some("remote-id-2@x.com"), 20, 2000),
];
let local = vec![snap("generated-random-id", 1, 10, 1000)];
let missing = compute_missing_uids(&remote, &local);
assert_eq!(missing, vec![2]);
}
}

View File

@@ -19,9 +19,12 @@
use crate::{
account::{
migration::{AccountModel, AccountType},
state::{DownloadState, DownloadStatus, TriggerType},
state::{
DownloadState, DownloadStatus, GapFillFolderStats, GapFillState, GapFillStatus,
TriggerType,
},
},
cache::imap::{download::flow::FetchDirection, mailbox::MailBox},
archive::imap::{download::flow::FetchDirection, mailbox::MailBox},
error::BichonResult,
imap::executor::ImapExecutor,
};
@@ -31,10 +34,11 @@ use flow::reconcile_mailboxes;
use rebuild::{rebuild_cache, rebuild_cache_by_date};
use std::time::Instant;
use tokio_util::sync::CancellationToken;
use tracing::{debug, warn};
use tracing::{debug, info, warn};
pub mod download_folders;
pub mod download_type;
pub mod gap_fill;
pub mod flow;
pub mod rebuild;
@@ -42,6 +46,7 @@ pub async fn process_imap_download(
account: &AccountModel,
token: CancellationToken,
trigger_type: TriggerType,
run_gap_fill: bool,
) -> BichonResult<()> {
assert_eq!(account.account_type, AccountType::IMAP);
let start_time = Instant::now();
@@ -122,7 +127,60 @@ pub async fn process_imap_download(
}
let local_mailboxes = MailBox::list_all(account_id)?;
match reconcile_mailboxes(account, &remote_mailboxes, &local_mailboxes, token).await {
let reconcile_result =
reconcile_mailboxes(account, &remote_mailboxes, &local_mailboxes, token.clone()).await;
// Gap-fill phase: only on explicit user request (manual download with
// "run gap-fill" checked). Enumerate every UID in the download folders and
// download anything missing locally. Not run on scheduled syncs. Gap-fill
// runs are tracked in their own state (independent of the download session)
// because they are repeatable until `failed == 0`.
if run_gap_fill {
GapFillState::start_run(account_id)?;
// Run inside a helper so a failure anywhere still finalizes the run:
// an abandoned active run would otherwise show as Running forever.
let run_outcome = gap_fill_phase(
account,
&local_mailboxes,
&remote_mailboxes,
token,
account_id,
)
.await;
let (cancelled, total_downloaded, total_failed) = match run_outcome {
Ok(v) => v,
Err(e) => {
warn!(account_id = account_id, "Gap-fill phase error: {:#?}", e);
(false, 0, 1)
}
};
let status = if cancelled {
GapFillStatus::Cancelled
} else if total_failed > 0 {
GapFillStatus::Failed
} else {
GapFillStatus::Success
};
GapFillState::finish_run(account_id, status, total_downloaded, total_failed)?;
let summary = if cancelled {
format!(
"Gap-fill cancelled: {} downloaded, {} failed",
total_downloaded, total_failed
)
} else {
format!(
"Gap-fill finished: {} downloaded, {} failed",
total_downloaded, total_failed
)
};
DownloadState::update_session_message(account_id, summary.clone())?;
info!(account_id = account_id, "{}", summary);
}
// Finalize session status AFTER all phases so stats/progress written
// during gap-fill are not dropped (update_session_status closes the active
// session, moving it into history).
match reconcile_result {
Ok(_) => DownloadState::update_session_status(account_id, DownloadStatus::Success, None)?,
Err(e) => {
let err_msg = format!("Email Download interrupted: {:#?}", e);
@@ -134,6 +192,7 @@ pub async fn process_imap_download(
)?;
}
}
let elapsed_time = start_time.elapsed().as_secs();
debug!(
"Account{{{}}} Incremental sync completed: {} seconds elapsed.",
@@ -141,3 +200,55 @@ pub async fn process_imap_download(
);
Ok(())
}
/// Runs the gap-fill phase across all download-folder mailboxes. Errors inside
/// are converted into a failed-run outcome instead of propagating, so the
/// caller can always finalize the active run.
async fn gap_fill_phase(
account: &AccountModel,
local_mailboxes: &[MailBox],
remote_mailboxes: &[MailBox],
token: CancellationToken,
account_id: u64,
) -> BichonResult<(bool, u64, u64)> {
let mut total_downloaded = 0u64;
let mut total_failed = 0u64;
let mut cancelled = false;
for local_mailbox in local_mailboxes {
let Some(remote) = remote_mailboxes.iter().find(|r| r.name == local_mailbox.name) else {
continue;
};
if token.is_cancelled() {
cancelled = true;
break;
}
DownloadState::set_current_folder(account_id, local_mailbox.name.clone())?;
match gap_fill::gap_fill_mailbox(account, local_mailbox, remote, token.clone()).await {
Ok(stats) => {
total_downloaded += stats.downloaded;
total_failed += stats.failed;
GapFillState::add_folder_result(account_id, local_mailbox.name.clone(), stats)?;
}
Err(e) => {
let err_msg = format!(
"Gap-fill failed for mailbox '{}': {:#?}",
local_mailbox.name, e
);
warn!(account_id = account_id, "{}", err_msg);
DownloadState::append_session_error(account_id, err_msg)?;
total_failed += 1; // count the mailbox as a failed unit
GapFillState::add_folder_result(
account_id,
local_mailbox.name.clone(),
GapFillFolderStats {
downloaded: 0,
failed: 1,
candidate_count: 0,
message: None,
},
)?;
}
}
}
Ok((cancelled, total_downloaded, total_failed))
}

View File

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

View File

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

View File

@@ -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;
@@ -30,7 +30,7 @@ use std::{sync::LazyLock, time::Duration};
use tokio::sync::Mutex;
use tokio::task::JoinHandle;
use tokio_util::sync::CancellationToken;
use tracing::{error, info, warn};
use tracing::{debug, error, info, warn};
static _DESCRIPTION: &str = "This task periodically synchronizes mailbox data for a specified account, ensuring that all local data is up-to-date.";
const TASK_INTERVAL: Duration = Duration::from_secs(10);
@@ -90,7 +90,7 @@ impl AccountDownTask {
let internal_token = task_token.clone();
Box::pin(async move {
if SYNC_TASKS.is_manual_running(account_id).await {
info!(
debug!(
"Account {}: Scheduled task skipped (Manual task is running).",
account_id
);
@@ -98,7 +98,7 @@ impl AccountDownTask {
}
if !SYNC_TASKS.try_set_busy(account_id).await {
warn!(
debug!(
"Account {}: Scheduled task skipped (Previous sync still active).",
account_id
);
@@ -141,6 +141,7 @@ impl AccountDownTask {
&account,
internal_token,
TriggerType::Scheduled,
false,
)
.await
{
@@ -211,7 +212,7 @@ impl AccountDownTask {
}
}
pub async fn start_manual_task(&self, account_id: u64) -> BichonResult<()> {
pub async fn start_manual_task(&self, account_id: u64, run_gap_fill: bool) -> BichonResult<()> {
{
if self.is_manual_running(account_id).await {
return Err(raise_error!(
@@ -253,7 +254,9 @@ impl AccountDownTask {
return;
}
if let Err(e) = process_imap_download(&account, token_clone, TriggerType::Manual).await
if let Err(e) =
process_imap_download(&account, token_clone, TriggerType::Manual, run_gap_fill)
.await
{
error!("Manual download failed for {}: {:?}", account_id, e);
let error_msg = format!("error in account download task: {:#?}", e);

View File

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

View File

@@ -17,6 +17,7 @@
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use crate::account::migration::AccountType;
use crate::account::state::{DownloadState, GapFillState};
use crate::context::Initialize;
use crate::{
{
@@ -25,7 +26,7 @@ use crate::{
utc_now,
};
use std::sync::LazyLock;
use tracing::info;
use tracing::{info, warn};
pub static BICHON_CONTEXT: LazyLock<BichonContext> = LazyLock::new(BichonContext::new);
@@ -65,6 +66,45 @@ impl BichonContext {
active_accounts.len()
);
for account in active_accounts {
// A Running session surviving startup is a leftover from a previous
// interrupted run; nothing is downloading yet at this point. Mark it
// Cancelled so the UI doesn't show a phantom "syncing" state. The
// scheduler starts regardless — its first tick runs immediately, so
// the interrupted run is caught up on, and the session's trigger
// stays Scheduled rather than showing a "Manual" the user never
// initiated.
match DownloadState::finalize_stale_session(account.id) {
Ok(true) => {
info!(
"Account {}: stale sync session finalized on startup.",
account.id
);
}
Err(e) => {
warn!(
"Failed to finalize stale session for account {}: {:#?}",
account.id, e
);
}
Ok(false) => {}
}
// Same for a leftover gap-fill run: a Running active run surviving
// startup is a phantom — nothing is scanning at this point.
match GapFillState::finalize_stale_run(account.id) {
Ok(true) => {
info!(
"Account {}: stale gap-fill run finalized on startup.",
account.id
);
}
Err(e) => {
warn!(
"Failed to finalize stale gap-fill run for account {}: {:#?}",
account.id, e
);
}
Ok(false) => {}
}
DOWNLOAD_CONTROLLER
.trigger_schedule(account.id, account.email)
.await

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -242,6 +242,14 @@ pub struct Settings {
)]
pub bichon_sync_concurrency: Option<u16>,
#[clap(
long,
env,
default_value = "90",
help = "IMAP socket read timeout in seconds (0 disables the timeout). Servers that throttle or burst slowly (e.g. Zoho) can pause for 30-60s between responses; keep this above the longest expected server silence so throttling surfaces as progress delay, not a failed sync."
)]
pub bichon_imap_timeout_seconds: u64,
#[clap(
long,
env,
@@ -394,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 {

View File

@@ -81,6 +81,24 @@ use tracing::{info, warn};
pub static ENVELOPE_MANAGER: LazyLock<IndexManager> = LazyLock::new(IndexManager::new);
/// Lightweight snapshot of a single envelope, used as the local side of
/// the gap-fill diff without materializing full `Envelope` structs.
///
/// Loads every document of the mailbox into memory at once — the caller
/// must not use this for mailboxes too large to hold in a full in-memory
/// pass. Documents without a message-id are excluded by
/// `get_envelope_snapshots_for_mailbox` since the diff relies on
/// message-id / fingerprint matching.
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct EnvelopeSnapshot {
pub message_id: String,
pub uid: u64,
pub size: u64,
/// Epoch millis (internal date).
pub internal_date: i64,
//pub subject: String,
}
pub struct IndexManager {
index: Arc<Index>,
index_writer: Arc<Mutex<IndexWriter>>,
@@ -262,7 +280,7 @@ impl IndexManager {
Box::new(TermQuery::new(account_term, IndexRecordOption::Basic))
}
fn mailbox_query(&self, account_id: u64, mailbox_id: u64) -> Box<dyn Query> {
pub(crate) fn mailbox_query(&self, account_id: u64, mailbox_id: u64) -> Box<dyn Query> {
let account_query = TermQuery::new(
Term::from_field_u64(SchemaTools::email_fields().f_account_id, account_id),
IndexRecordOption::Basic,
@@ -311,6 +329,68 @@ impl IndexManager {
Ok(result)
}
/// Returns lightweight snapshots of every envelope stored for a mailbox:
/// message-id, uid, size, internal date (epoch millis), subject.
/// Used by gap-fill to compute the local side of the diff without
/// materializing full `Envelope` structs.
pub fn get_envelope_snapshots_for_mailbox(
&self,
account_id: u64,
mailbox_id: u64,
) -> BichonResult<Vec<EnvelopeSnapshot>> {
let query = self.mailbox_query(account_id, mailbox_id);
let fields = SchemaTools::email_fields();
let searcher = self.create_searcher()?;
let docs = searcher
.search(&query, &DocSetCollector)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
let mut snapshots = Vec::with_capacity(docs.len());
for doc_address in docs {
let doc = searcher
.doc::<TantivyDocument>(doc_address)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
let message_id = doc
.get_first(fields.f_message_id)
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
// Skip documents without a message-id: they are useless for
// gap-fill (the diff matches on message-id / fingerprint) and
// would otherwise surface as spurious "missing" entries in the
// difference set. Other fields keep their fallback defaults.
if message_id.is_empty() {
continue;
}
let uid = doc
.get_first(fields.f_uid)
.and_then(|v| v.as_u64())
.unwrap_or(0);
let size = doc
.get_first(fields.f_size)
.and_then(|v| v.as_u64())
.unwrap_or(0);
let internal_date = doc
.get_first(fields.f_internal_date)
.and_then(|v| v.as_i64())
.unwrap_or(0);
// let subject = doc
// .get_first(fields.f_subject)
// .and_then(|v| v.as_str())
// .unwrap_or("")
// .to_string();
snapshots.push(EnvelopeSnapshot {
message_id,
uid,
size,
internal_date,
//subject,
});
}
Ok(snapshots)
}
/// Check whether a specific Message-ID exists in a mailbox.
/// Uses a TermQuery — O(1) per call, no allocation proportional to
/// mailbox size. Suitable for large mailboxes where
@@ -485,7 +565,10 @@ impl IndexManager {
}
}
if !participant_queries.is_empty() {
subqueries.push((Occur::Must, Box::new(BooleanQuery::new(participant_queries))));
subqueries.push((
Occur::Must,
Box::new(BooleanQuery::new(participant_queries)),
));
}
}
@@ -2238,9 +2321,7 @@ mod tests {
]))
};
let docs = searcher
.search(&query, &DocSetCollector)
.unwrap();
let docs = searcher.search(&query, &DocSetCollector).unwrap();
let mut ids: Vec<String> = Vec::new();
for addr in docs {
@@ -2582,9 +2663,7 @@ mod tests {
writer.commit().unwrap();
}
let reader = index
.reader()
.expect("reader");
let reader = index.reader().expect("reader");
let searcher = reader.searcher();
let query = TermQuery::new(
@@ -2611,19 +2690,13 @@ mod tests {
.get_first(f.f_ingest_at)
.and_then(|v| v.as_i64())
.unwrap_or(0);
let uid = doc
.get_first(f.f_uid)
.and_then(|v| v.as_u64())
.unwrap_or(0);
let uid = doc.get_first(f.f_uid).and_then(|v| v.as_u64()).unwrap_or(0);
results.push((ingest_at, uid));
}
// Verify primary sort by ingest_at is correct
for w in results.windows(2) {
assert!(
w[0].0 <= w[1].0,
"ingest_at must be non-decreasing"
);
assert!(w[0].0 <= w[1].0, "ingest_at must be non-decreasing");
}
// Verify deterministic: run again, same order
@@ -2660,7 +2733,12 @@ mod tests {
println!("IMAP UID mapping (position → ingest_at, uid):");
for (pos, (ingest_at, uid)) in results.iter().enumerate() {
println!(" UID {} → (ingest_at={}, original_uid={})", pos + 1, ingest_at, uid);
println!(
" UID {} → (ingest_at={}, original_uid={})",
pos + 1,
ingest_at,
uid
);
}
}
}

View File

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

View File

@@ -18,7 +18,7 @@
use crate::error::code::ErrorCode;
use crate::raise_error;
use crate::settings::proxy::Proxy;
use crate::settings::{cli::SETTINGS, proxy::Proxy};
use crate::utils::tls::establish_tls_stream;
use crate::{error::BichonResult, imap::session::SessionStream};
use base64::{engine::general_purpose, Engine as _};
@@ -90,9 +90,16 @@ pub(crate) async fn establish_tcp_connection_with_timeout(
let tcp_stream = connect_with_optional_proxy(use_proxy, address).await?;
let mut timeout_stream = TimeoutStream::new(tcp_stream);
// Set read and write timeouts
// Set read and write timeouts. The read timeout bounds how long the sync
// task blocks waiting for a slow IMAP server between commands; a hung
// server therefore surfaces as a network error (and retry) instead of a
// silently stuck download. 0 disables the read timeout (server decides).
let read_timeout = SETTINGS
.bichon_imap_timeout_seconds
.checked_sub(1)
.map(|seconds| Duration::from_secs(seconds.max(1)));
timeout_stream.set_write_timeout(Some(Duration::from_secs(15)));
timeout_stream.set_read_timeout(Some(Duration::from_secs(30)));
timeout_stream.set_read_timeout(read_timeout);
// Return the timeout-wrapped TCP stream as a Pin
Ok(Box::pin(timeout_stream))

View File

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

View File

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

View File

@@ -24,12 +24,13 @@ use bichon_core::account::migration::{AccountModel, AccountType};
use bichon_core::account::payload::{
filter_accessible_accounts, AccountCreateRequest, AccountUpdateRequest, MinimalAccount,
};
use bichon_core::account::state::DownloadState;
use bichon_core::account::state::{DownloadState, GapFillState};
use bichon_core::account::stats::AccountStats;
use bichon_core::account::view::AccountResp;
use bichon_core::cache::imap::task::SYNC_TASKS;
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;
@@ -37,10 +38,22 @@ use bichon_core::users::UserModel;
use poem_openapi::param::{Path, Query};
use poem_openapi::payload::Json;
use poem_openapi::OpenApi;
use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet};
pub struct AccountApi;
/// Request body for `POST /accounts/:account_id/start-download`.
/// `Default` keeps the handler working for older clients that send no body.
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize, poem_openapi::Object)]
pub struct StartDownloadRequest {
/// When true, run the gap-fill phase (enumerate all UIDs in the download
/// folders and download anything missing locally) after the incremental
/// sync. Defaults to false; omitted by older clients.
#[serde(default)]
pub run_gap_fill: bool,
}
#[OpenApi(prefix_path = "/api/v1", tag = "ApiTags::Account")]
impl AccountApi {
/// Get account details by account ID
@@ -74,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(())
}
@@ -88,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))
}
@@ -107,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
@@ -196,6 +231,30 @@ impl AccountApi {
Ok(Json(state))
}
/// Get the gap-fill history of an account (independent of download sessions)
#[oai(
path = "/accounts/:account_id/gap-fill-stats",
method = "get",
operation_id = "accounts_gap_fill_state"
)]
async fn accounts_gap_fill_state(
&self,
/// The account ID to check gap-fill state for
account_id: Path<u64>,
context: WrappedContext,
) -> ApiResult<Json<GapFillState>> {
let account_id = account_id.0;
AccountModel::check_account_exists(account_id)?;
context.require_permission(Some(account_id), Permission::ACCOUNT_READ_DETAILS)?;
let state = GapFillState::get(account_id)?;
let state = state.unwrap_or(GapFillState {
account_id,
active: None,
history: Vec::new(),
});
Ok(Json(state))
}
/// Start a manual download task for an account
#[oai(
path = "/accounts/:account_id/start-download",
@@ -206,6 +265,7 @@ impl AccountApi {
&self,
/// The account ID to start download for
account_id: Path<u64>,
body: Json<StartDownloadRequest>,
context: WrappedContext,
) -> ApiResult<()> {
let account_id = account_id.0;
@@ -217,7 +277,12 @@ impl AccountApi {
))?;
}
context.require_permission(Some(account_id), Permission::ACCOUNT_MANAGE)?;
SYNC_TASKS.start_manual_task(account_id).await?;
SYNC_TASKS.start_manual_task(account_id, body.run_gap_fill).await?;
emit(Event::AccountDownloadStarted {
user: context.user.username.clone(),
account_id,
run_gap_fill: body.run_gap_fill,
});
Ok(())
}
@@ -251,6 +316,10 @@ impl AccountApi {
))?;
}
SYNC_TASKS.cancel_manual_task(account_id).await;
emit(Event::AccountDownloadStopped {
user: context.user.username.clone(),
account_id,
});
Ok(())
}
@@ -306,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(())
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -42,6 +42,7 @@ export enum DownloadStatus {
export enum TriggerType {
Manual = "Manual",
Scheduled = "Scheduled",
SyncFull = "SyncFull",
}
export enum FolderStatus {
@@ -66,6 +67,35 @@ export interface AccountError {
error: string;
}
export interface GapFillFolderStats {
downloaded: number;
failed: number;
candidate_count: number;
message?: string | null;
}
export enum GapFillStatus {
Running = "Running",
Success = "Success",
Failed = "Failed",
Cancelled = "Cancelled",
}
export interface GapFillRun {
started_at: number;
finished_at: number | null;
status: GapFillStatus;
folders: Record<string, GapFillFolderStats>;
downloaded: number;
failed: number;
}
export interface GapFillState {
account_id: number;
active: GapFillRun | null;
history: GapFillRun[];
}
export interface DownloadSession {
start_time: number;
end_time: number | null;
@@ -167,6 +197,11 @@ export const download_state = async (account_id: number) => {
return response.data;
};
export const gap_fill_state = async (account_id: number) => {
const response = await axiosInstance.get<GapFillState>(`api/v1/accounts/${account_id}/gap-fill-stats`);
return response.data;
};
export const create_account = async (data: Record<string, any>) => {
const response = await axiosInstance.post("api/v1/account", data);
return response.data;
@@ -188,8 +223,8 @@ export const remove_account = async (account_id: number) => {
};
export const start_account_download = async (account_id: number) => {
const response = await axiosInstance.post(`api/v1/accounts/${account_id}/start-download`);
export const start_account_download = async (account_id: number, run_gap_fill = false) => {
const response = await axiosInstance.post(`api/v1/accounts/${account_id}/start-download`, { run_gap_fill });
return response.data;
};

61
web/src/api/audit/api.ts Normal file
View 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
}

View 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
}

View File

@@ -103,6 +103,8 @@ export interface User {
account_permissions: Record<number, string[]>
created_at: number;
updated_at: number;
sso_id?: string | null;
sso_provider?: string | null;
}
type Theme = 'dark' | 'light'

View File

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

View File

@@ -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']),
},
],
},
],

View File

@@ -45,7 +45,7 @@ interface PaginationProps {
setPageSize: (pageSize: number) => void
}
export function AttachmentListPagination({
export function TablePagination({
totalItems,
pageIndex,
pageSize,

View File

@@ -21,6 +21,9 @@ import { useNavigate, useLocation } from '@tanstack/react-router'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { resetToken } from '@/stores/authStore'
import { useTranslation } from 'react-i18next'
import { useCurrentUser } from '@/hooks/use-current-user'
import { useEdition } from '@/hooks/use-edition'
import { useState } from 'react'
interface SignOutDialogProps {
open: boolean
@@ -31,9 +34,17 @@ export function SignOutDialog({ open, onOpenChange }: SignOutDialogProps) {
const navigate = useNavigate()
const location = useLocation()
const { t } = useTranslation()
const handleSignOut = () => {
resetToken()
const currentPath = location.href
const { user } = useCurrentUser()
const { isPro, features } = useEdition()
const [isLoading, setIsLoading] = useState(false)
const isSsoUser =
isPro &&
features.includes('sso') &&
!!user?.sso_provider &&
user.sso_provider !== ''
const goToSignIn = (currentPath: string) => {
navigate({
to: '/sign-in',
search: { redirect: currentPath },
@@ -41,19 +52,73 @@ export function SignOutDialog({ open, onOpenChange }: SignOutDialogProps) {
})
}
const localSignOut = () => {
resetToken()
goToSignIn(location.href)
}
const handleConfirm = () => {
if (!isSsoUser) {
localSignOut()
return
}
setIsLoading(true)
// Only sign out of bichon; keep the SSO session for one-click sign-in.
fetch('/api/auth/oidc/local-logout', { redirect: 'follow' })
.catch(() => {})
.finally(() => {
setIsLoading(false)
localSignOut()
})
}
return (
<ConfirmDialog
open={open}
onOpenChange={onOpenChange}
title={t('sign_out.title', 'Sign out')}
desc={t(
'sign_out.desc',
'Are you sure you want to sign out? You will need to sign in again to access your account.'
)}
confirmText={t('sign_out.confirm', 'Sign out')}
desc={
isSsoUser
? t(
'sign_out.sso_desc',
'Signing out of Bichon only keeps your SSO session (e.g. Keycloak) active. For full security, choose "Sign out and end SSO session".'
)
: t(
'sign_out.desc',
'Are you sure you want to sign out? You will need to sign in again to access your account.'
)
}
confirmText={
isSsoUser
? t('sign_out.confirm_sso', 'Sign out of Bichon only')
: t('sign_out.confirm', 'Sign out')
}
destructive
handleConfirm={handleSignOut}
isLoading={isLoading}
handleConfirm={handleConfirm}
className="sm:max-w-sm"
/>
>
{isSsoUser && (
<div className='grid gap-2'>
<button
type='button'
className='inline-flex h-10 items-center justify-center gap-2 rounded-md border border-destructive/50 bg-background px-4 text-sm font-medium text-destructive transition-colors hover:bg-destructive/10'
disabled={isLoading}
onClick={() => {
resetToken()
window.location.href = '/api/auth/oidc/logout'
}}
>
{t('sign_out.full_sign_out', 'Sign out and end SSO session')}
</button>
<p className='text-muted-foreground px-2 text-xs'>
{t(
'sign_out.sso_warning',
'This will end your SSO session (e.g. Keycloak) and sign you out of all applications using it.'
)}
</p>
</div>
)}
</ConfirmDialog>
)
}

View File

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

View File

@@ -17,6 +17,7 @@
// along with this program. If not, see <http://www.gnu.org/licenses/>.
import { useState } from 'react'
import { DotsHorizontalIcon } from '@radix-ui/react-icons'
import { Row } from '@tanstack/react-table'
import { IconEdit, IconPlayerPlay, IconPlayerStop, IconShieldLock, IconTrash } from '@tabler/icons-react'
@@ -33,9 +34,10 @@ import { useAccountContext } from '../context'
import { Mailbox, MessageSquareMore, Settings } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { useCurrentUser } from '@/hooks/use-current-user'
import { AccountModel, cancel_account_download, start_account_download } from '@/api/account/api'
import { AccountModel, cancel_account_download } from '@/api/account/api'
import { toast } from '@/hooks/use-toast'
import { useNavigate } from '@tanstack/react-router'
import { StartDownloadDialog } from './start-download-dialog'
interface DataTableRowActionsProps {
row: Row<AccountModel>
@@ -45,6 +47,7 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) {
const { t } = useTranslation()
const { setOpen, setCurrentRow } = useAccountContext()
const navigate = useNavigate()
const [startDialogOpen, setStartDialogOpen] = useState(false)
const account_type = row.original.account_type;
const { require_any_permission } = useCurrentUser()
@@ -64,16 +67,7 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) {
const showDownload = !isDeleting && account_type === 'IMAP' && hasPermission;
const handleStartDownload = async () => {
try {
await start_account_download(row.original.id);
toast({ title: t('accounts.downloadStarted') });
} catch (error: any) {
toast({
variant: "destructive",
title: t('accounts.downloadFailed'),
description: error.response?.data?.message || error.message
});
}
setStartDialogOpen(true)
}
@@ -198,6 +192,11 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) {
</DropdownMenuItem>}
</DropdownMenuContent>
</DropdownMenu>
<StartDownloadDialog
row={row.original}
open={startDialogOpen}
onOpenChange={setStartDialogOpen}
/>
</>
)
}

View File

@@ -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) => {

View File

@@ -24,7 +24,9 @@ import { useTranslation } from 'react-i18next';
import { useCurrentUser } from '@/hooks/use-current-user';
import { toast } from '@/hooks/use-toast';
import { ToastAction } from '@/components/ui/toast';
import { AccountModel } from '@/api/account/api';
import { useQuery } from '@tanstack/react-query'
import { download_state, AccountModel, DownloadStatus } from '@/api/account/api';
import { Loader2 } from 'lucide-react'
interface Props {
row: Row<AccountModel>
@@ -35,38 +37,61 @@ export function RunningStateCellAction({ row }: Props) {
const { setOpen, setCurrentRow } = useAccountContext()
const { require_any_permission } = useCurrentUser()
const hasPermission = require_any_permission(['system:root', 'account:read_details'], row.original.id)
const { data: state } = useQuery({
queryKey: ['running-state', row.original.id],
queryFn: () => download_state(row.original.id),
refetchInterval: (query) => {
const s = query.state.data?.active_session
return s && s.status === DownloadStatus.Running ? 5000 : false
},
})
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") {
if (row.original.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)
const running = state?.active_session
const isRunning = !!running && running.status === DownloadStatus.Running
return (
<Button variant='ghost' className="h-auto p-1" onClick={() => {
if (hasPermission) {
setCurrentRow(row.original)
setOpen('running-state')
} else {
toast({
variant: 'destructive',
title: 'Forbidden',
description: 'You do not have permission to view this account.',
action: (
<ToastAction altText="Close">
Close
</ToastAction>
),
})
}
}}>
<span
className="text-xs text-primary cursor-pointer underline underline-offset-2 hover:opacity-80 transition-opacity"
>
{t('accounts.viewDetails')}
</span>
</Button>
<div className="flex items-center justify-center gap-2">
{isRunning && (
<span className="inline-flex items-center gap-1.5 rounded-full bg-blue-500/10 text-blue-600 border border-blue-500/20 px-2 py-0.5 text-[11px] font-medium shrink-0">
<Loader2 className="h-3 w-3 animate-spin" />
{t('accounts.runningState.syncing')}
</span>
)}
<Button variant='ghost' className="h-auto p-1" onClick={() => {
if (hasPermission) {
setCurrentRow(row.original)
setOpen('running-state')
} else {
toast({
variant: 'destructive',
title: 'Forbidden',
description: 'You do not have permission to view this account.',
action: (
<ToastAction altText="Close">
Close
</ToastAction>
),
})
}
}}>
<span
className="text-xs text-primary cursor-pointer underline underline-offset-2 hover:opacity-80 transition-opacity"
>
{t('accounts.viewDetails')}
</span>
</Button>
</div>
)
}

View File

@@ -26,7 +26,8 @@ import {
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { useQuery } from '@tanstack/react-query'
import { download_state, AccountModel, FolderProgress } from '@/api/account/api'
import { useEffect, useState } from 'react'
import { download_state, gap_fill_state, DownloadStatus, AccountModel, FolderProgress, GapFillRun, GapFillStatus } from '@/api/account/api'
import { format } from 'date-fns'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Badge } from '@/components/ui/badge'
@@ -114,11 +115,19 @@ function FolderDetailItem({ f, t }: { f: FolderProgress, t: (key: string) => str
{f.message && (
<div className="px-4 pb-4">
<div className="bg-muted/50 border rounded-lg p-3 flex gap-3 items-start">
<Info className="w-4 h-4 text-muted-foreground mt-0.5 shrink-0" />
<div className={`border rounded-lg p-3 flex gap-3 items-start ${f.message.toLowerCase().includes('slow') || f.message.toLowerCase().includes('limiting')
? 'bg-amber-500/10 border-amber-500/30'
: 'bg-muted/50'}`}>
{f.message.toLowerCase().includes('slow') || f.message.toLowerCase().includes('limiting') ? (
<AlertTriangle className="w-4 h-4 text-amber-600 mt-0.5 shrink-0" />
) : (
<Info className="w-4 h-4 text-muted-foreground mt-0.5 shrink-0" />
)}
<div className="space-y-0.5">
<p className="text-[10px] font-bold text-foreground">{t('accounts.runningState.message')}:</p>
<p className="text-[10px] font-medium text-muted-foreground leading-relaxed">{f.message}</p>
<p className={`text-[10px] font-medium leading-relaxed ${f.message.toLowerCase().includes('slow') || f.message.toLowerCase().includes('limiting')
? 'text-amber-700'
: 'text-muted-foreground'}`}>{f.message}</p>
</div>
</div>
</div>
@@ -127,18 +136,109 @@ function FolderDetailItem({ f, t }: { f: FolderProgress, t: (key: string) => str
)
}
function GapFillRunDetail({ run, t }: { run: GapFillRun, t: (key: string) => string }) {
const folderEntries = Object.entries(run.folders)
if (folderEntries.length === 0) {
return (
<div className="text-center py-8 text-muted-foreground italic text-xs">
{t('accounts.runningState.empty.no_gap_fill_folders')}
</div>
)
}
const isActive = run.status === GapFillStatus.Running
return (
<div className="space-y-3">
{folderEntries.map(([name, stats]) => {
const pct = stats.candidate_count > 0
? Math.min(100, Math.round((stats.downloaded / stats.candidate_count) * 100))
: 0
return (
<div key={name} className="py-1.5 border-b border-border/50 last:border-b-0">
<div className="flex items-center justify-between gap-2">
<span className="text-xs font-bold text-foreground truncate">{name}</span>
<span className="text-[10px] font-bold text-muted-foreground whitespace-nowrap">
{isActive && stats.candidate_count > 0 ? (
<span className="text-blue-600">
{stats.downloaded} <span className="opacity-50">/</span> {stats.candidate_count}
</span>
) : (
<>
<span className="text-blue-600">{stats.downloaded} {t('accounts.runningState.gap_fill_downloaded_suffix')}</span>
{stats.failed > 0 && (
<>
<span className="mx-1 opacity-30">·</span>
<span className="text-destructive">{stats.failed} {t('accounts.runningState.gap_fill_failed_suffix')}</span>
</>
)}
</>
)}
</span>
</div>
{isActive && stats.candidate_count > 0 && (
<div className="mt-1.5 h-1.5 w-full rounded-full bg-muted overflow-hidden">
<div className="h-full rounded-full bg-blue-500 transition-all duration-500" style={{ width: `${pct}%` }} />
</div>
)}
{stats.message && (
<div className="mt-1.5 flex items-start gap-1.5">
<AlertTriangle className="w-3 h-3 text-amber-600 mt-0.5 shrink-0" />
<p className="text-[10px] font-medium text-amber-700 leading-relaxed">{stats.message}</p>
</div>
)}
</div>
)
})}
</div>
)
}
export function RunningStateDialog({ currentRow, open, onOpenChange }: Props) {
const { t } = useTranslation();
const { data: state, isLoading } = useQuery({
queryKey: ['running-state', currentRow.id],
queryFn: () => download_state(currentRow.id),
refetchInterval: 5000,
refetchInterval: (query) => {
const s = query.state.data?.active_session
return s && s.status === DownloadStatus.Running ? 5000 : false
},
enabled: open && !!currentRow.id,
})
const { data: gapFillData } = useQuery({
queryKey: ['gap-fill-state', currentRow.id],
queryFn: () => gap_fill_state(currentRow.id),
refetchInterval: (query) => {
const a = query.state.data?.active
return a && a.status === GapFillStatus.Running ? 5000 : false
},
enabled: open && !!currentRow.id,
})
const session = state?.active_session
const history = state?.history || []
const isRunning = !!session && session.status === DownloadStatus.Running
// When the poll is active, tick once per second so the "elapsed" and
// "last updated Xs ago" readouts stay fresh between refetches.
const [, setTick] = useState(0)
useEffect(() => {
if (!isRunning) return
const id = setInterval(() => setTick((v) => v + 1), 1000)
return () => clearInterval(id)
}, [isRunning])
const elapsedSec = session && isRunning
? Math.max(0, Math.floor((Date.now() - new Date(session.start_time).getTime()) / 1000))
: 0
const formatDur = (s: number) => {
const h = Math.floor(s / 3600)
const m = Math.floor((s % 3600) / 60)
const sec = s % 60
if (h > 0) return `${h}h ${m}m ${sec}s`
if (m > 0) return `${m}m ${sec}s`
return `${sec}s`
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
@@ -165,6 +265,10 @@ export function RunningStateDialog({ currentRow, open, onOpenChange }: Props) {
{t('accounts.runningState.tabs.history')}
<Badge variant="secondary" className="ml-2 h-4 px-1 text-[10px] font-bold">{history.length}</Badge>
</TabsTrigger>
<TabsTrigger value="gapfill" className="whitespace-nowrap data-[state=active]:border-b-2 data-[state=active]:border-primary rounded-none h-full bg-transparent shadow-none px-0 text-xs sm:text-sm font-bold">
{t('accounts.runningState.tabs.gap_fill')}
{gapFillData?.active && <Badge variant="secondary" className="ml-2 h-4 px-1 text-[10px] font-bold animate-pulse">{t('accounts.runningState.syncing')}</Badge>}
</TabsTrigger>
</TabsList>
</div>
@@ -202,6 +306,23 @@ export function RunningStateDialog({ currentRow, open, onOpenChange }: Props) {
</div>
</div>
</div>
{isRunning && (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div className="p-3 sm:p-4 rounded-xl border bg-card shadow-sm flex items-center justify-between sm:block">
<p className="text-[10px] font-bold text-muted-foreground uppercase mb-1">{t('accounts.runningState.session.elapsed')}</p>
<div className="text-sm font-bold font-mono text-blue-600 flex items-center gap-1.5">
<Loader2 className="h-3.5 w-3.5 animate-spin" />
{formatDur(elapsedSec)}
</div>
</div>
{session.current_folder && (
<div className="p-3 sm:p-4 rounded-xl border bg-card shadow-sm flex items-center justify-between sm:block">
<p className="text-[10px] font-bold text-muted-foreground uppercase mb-1">{t('accounts.runningState.session.current_folder')}</p>
<div className="text-sm font-bold text-foreground truncate">{session.current_folder}</div>
</div>
)}
</div>
)}
<Tabs defaultValue="folders" className="w-full">
<TabsList className="bg-muted mb-3 h-8">
<TabsTrigger value="folders" className="text-[11px] font-bold">
@@ -388,6 +509,55 @@ export function RunningStateDialog({ currentRow, open, onOpenChange }: Props) {
</div>
</ScrollArea>
</TabsContent>
<TabsContent value="gapfill" className="h-full m-0 data-[state=active]:flex flex-col">
<ScrollArea className="flex-1">
<div className="p-4 sm:p-6 space-y-4">
{gapFillData?.active && (
<div className="rounded-xl border bg-card shadow-sm p-4">
<div className="flex items-center justify-between mb-3">
<p className="text-[10px] font-bold text-muted-foreground uppercase">{t('accounts.runningState.gap_fill_active')}</p>
<StatusBadge status={gapFillData.active.status} />
</div>
<GapFillRunDetail run={gapFillData.active} t={t} />
</div>
)}
{(!gapFillData?.history || gapFillData.history.length === 0) ? (
<div className="text-center py-20 text-muted-foreground italic text-sm">
{t('accounts.runningState.empty.no_gap_fill_history')}
</div>
) : (
<Accordion type="single" collapsible className="space-y-3">
{[...gapFillData.history].reverse().map((run, i) => (
<AccordionItem key={i} value={`gapfill-${i}`} className="border rounded-xl bg-card shadow-sm px-4 border-border overflow-hidden">
<AccordionTrigger className="hover:no-underline py-4">
<div className="flex flex-col sm:flex-row sm:items-center justify-between w-full pr-4 gap-2">
<div className="flex items-center gap-3">
<div className="text-xs sm:text-xs font-bold font-mono text-foreground">
{format(new Date(run.started_at), 'yyyy-MM-dd HH:mm:ss')}
</div>
<StatusBadge status={run.status} />
</div>
<span className="text-[10px] font-bold text-muted-foreground bg-muted px-2 py-0.5 rounded-full self-start sm:self-auto">
<span className="text-blue-600">{run.downloaded} {t('accounts.runningState.gap_fill_downloaded_suffix')}</span>
{run.failed > 0 && (
<>
<span className="mx-1 opacity-30">·</span>
<span className="text-destructive">{run.failed} {t('accounts.runningState.gap_fill_failed_suffix')}</span>
</>
)}
</span>
</div>
</AccordionTrigger>
<AccordionContent className="pb-4 border-t pt-4 mt-1 border-border">
<GapFillRunDetail run={run} t={t} />
</AccordionContent>
</AccordionItem>
))}
</Accordion>
)}
</div>
</ScrollArea>
</TabsContent>
</>
)}
</div>

View File

@@ -0,0 +1,88 @@
//
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
import { useEffect, useState } from 'react'
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import { Label } from '@/components/ui/label'
import { useTranslation } from 'react-i18next'
import { toast } from '@/hooks/use-toast'
import { start_account_download, AccountModel } from '@/api/account/api'
interface Props {
row: AccountModel
open: boolean
onOpenChange: (open: boolean) => void
}
export function StartDownloadDialog({ row, open, onOpenChange }: Props) {
const { t } = useTranslation()
const [runGapFill, setRunGapFill] = useState(false)
const [submitting, setSubmitting] = useState(false)
useEffect(() => {
if (open) {
setRunGapFill(false)
setSubmitting(false)
}
}, [open])
const handleConfirm = async () => {
setSubmitting(true)
try {
await start_account_download(row.id, runGapFill)
toast({ title: t('accounts.downloadStarted') })
onOpenChange(false)
} catch (error: any) {
toast({
variant: 'destructive',
title: t('accounts.downloadFailed'),
description: error.response?.data?.message || error.message,
})
} finally {
setSubmitting(false)
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('accounts.startDownload')}</DialogTitle>
<DialogDescription>
{t('accounts.startDownloadConfirmDesc')}
</DialogDescription>
</DialogHeader>
<div className="flex items-center gap-2 py-2">
<Checkbox id="run-gap-fill" checked={runGapFill} onCheckedChange={(v) => setRunGapFill(!!v)} />
<Label htmlFor="run-gap-fill">{t('accounts.runGapFill')}</Label>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={submitting}>
{t('common.cancel')}
</Button>
<Button onClick={handleConfirm} disabled={submitting}>
{t('accounts.startDownload')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

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

View File

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

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

View File

@@ -53,11 +53,12 @@ export function UserAuthForm({ className, ...props }: UserAuthFormProps) {
const { setTheme } = useTheme();
const navigate = useNavigate()
const { t } = useTranslation()
const { isPro } = useEdition()
const { search } = useLocation();
const redirect = toSearchParams(search).get('redirect') || '/';
const { isPro, features } = useEdition()
const ssoEnabled = isPro && features.includes('sso')
const formSchema = getFormSchema(t)
const form = useForm<LoginFormValues>({
resolver: zodResolver(formSchema),
@@ -159,7 +160,7 @@ export function UserAuthForm({ className, ...props }: UserAuthFormProps) {
{t('auth.login')}
</Button>
{isPro && (
{ssoEnabled && (
<Button
variant='outline'
className='mt-2'

View 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',
})
})
})

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

View File

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

View 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
}

View File

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

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

View File

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

View File

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

View File

@@ -98,6 +98,7 @@
"allMailSkipped": "تم تحديد المجلدات القياسية. تم تخطي 'جميع رسائل البريد' لتجنب التكرارات.",
"areYouSureYouWantTo": "هل أنت متأكد من أنك تريد {{action}} هذا الحساب؟",
"auth": "المصادقة",
"authPassword": "كلمة مرور المصادقة",
"authType": "نوع_المصادقة",
"autoConfiguring": "جاري التكوين التلقائي…",
"autoDiscover": "اكتشاف إعدادات الخادم تلقائيًا",
@@ -116,6 +117,7 @@
"clickSaveWhenDone": "انقر على حفظ عند الانتهاء.",
"continue": "متابعة",
"createdAt": "تاريخ الإنشاء",
"creating": "جاري إنشاء الحساب...",
"creationFailed": "فشل الإنشاء، يرجى المحاولة مرة أخرى لاحقًا",
"cronAdvanced": "تعبير متقدم",
"cronDaily": "يومياً",
@@ -240,6 +242,7 @@
"imapPortPlaceholder": "مثال: 993",
"imapProxy": "استخدم وكيل SOCKS5 لاتصالات IMAP.",
"incDownload": "الفاصل",
"incSync": "فترة التزامن",
"lastSync": "آخر مزامنة",
"leaveEmptyToKeepExisting": "اترك فارغًا للاحتفاظ بكلمة المرور الحالية، أو أدخل كلمة مرور جديدة لتحديثها.",
"leaveEmptyToKeepPassword": "اترك فارغًا للاحتفاظ بكلمة المرور الحالية",
@@ -271,6 +274,7 @@
"refreshToken": "رمز التحديث",
"refreshTokenCopiedToClipboard": "تم نسخ رمز التحديث إلى الحافظة",
"relative": "نسبي",
"runGapFill": "فحص البريد الجديد وتنزيله، مع ملء الرسائل القديمة المفقودة محلياً تلقائياً",
"runningState": {
"account": {
"id": "معرّف الحساب"
@@ -283,24 +287,34 @@
"no_active_download": "لا يوجد تنزيل نشط",
"no_errors_current": "لا توجد أخطاء في الجلسة الحالية",
"no_errors_session": "لا توجد أخطاء في هذه الجلسة",
"no_gap_fill_folders": "لا توجد مجلدات لمزامنة الرسائل المفقودة",
"no_gap_fill_history": "لا يوجد سجل لاستكمال الرسائل",
"no_global_errors": "لا توجد أخطاء عامة",
"no_history": "لا يوجد سجل"
},
"folders": "صناديق البريد",
"gap_fill_active": "عملية الاستكمال قيد التشغيل",
"gap_fill_downloaded_suffix": "تم تنزيلها",
"gap_fill_failed_suffix": "فشلت",
"latest": "الأحدث",
"loading": {
"fetching_account_state": "جارٍ تحميل حالة الحساب..."
},
"message": "رسالة",
"session": {
"current_folder": "مجلد البريد الحالي",
"elapsed": "الوقت المنقضي",
"last_update": "آخر تحديث",
"started_at": "وقت البدء",
"status": "الحالة",
"trigger": "المشغّل"
"trigger": "طريقة المشغّل"
},
"syncing": "جاري المزامنة",
"tabs": {
"active_session": "الجلسة النشطة",
"errors": "أخطاء",
"folders": "صناديق البريد",
"gap_fill": "استكمال الرسائل الناقصة",
"history": "السجل"
}
},
@@ -321,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": "منذ تاريخ محدد",
@@ -341,6 +363,7 @@
"sinceRelativeDesc": "تنزيل رسائل الفترة الأخيرة فقط (مثل آخر 3 أشهر). يتحرك تاريخ البدء تلقائياً مع مرور الوقت.",
"sinceRelativeValue": "تنزيل رسائل البريد الإلكتروني من آخر",
"startDownload": "بدء التنزيل",
"startDownloadConfirmDesc": "بدء تنزيل بيانات الحسابات المحددة؟",
"state": "الحالة",
"status": "الحالة",
"step": "الخطوة {{index}}",
@@ -457,6 +480,7 @@
"downloading": "جاري التنزيل...",
"emailMessageNotFound": "تعذر العثور على رسالة البريد الإلكتروني الأصلية. ربما تم حذفها.",
"name": "اسم الملف",
"preview": "معاينة المرفق",
"search_input_placeholder": "بحث عن المرفقات (استخدم \" \" للبحث عن عبارة)",
"sender": "المرسل",
"sender_with_count": "المرسل ({{count}})",
@@ -474,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": "كلمة مرور غير صالحة. الرجاء المحاولة مرة أخرى.",
@@ -484,6 +572,7 @@
"sessionExpired": "انتهت صلاحية الجلسة!",
"sessionExpiredDesc": "انتهت جلستك بسبب عدم النشاط. يرجى تسجيل الدخول مرة أخرى للمتابعة.",
"somethingWentWrong": "حدث خطأ ما",
"ssoLogin": "تسجيل الدخول عبر SSO",
"username": "اسم المستخدم",
"welcome": "مرحبًا بك في بيشون",
"youWillNeedToLogInAgain": "ستحتاج إلى تسجيل الدخول مرة أخرى للوصول إلى حسابك."
@@ -644,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": "أدخل اسم مجلد مخصص",
@@ -667,6 +759,7 @@
"modeHeaderDesc": "قراءة X-Gmail-Labels / X-Bichon-Metadata من الملف. يعتمد على اسم الملف كبديل.",
"noAccountFound": "لم يتم العثور على حساب.",
"noFileYet": "لم يتم اختيار أي ملف بعد",
"noFilesSelected": "لم يتم تحديد أي ملفات",
"noMailboxFound": "لم يتم العثور على صندوق بريد.",
"noMailboxes": "لم يتم العثور على صناديق بريد في هذا الحساب.",
"orClick": "أو انقر للتصفح",
@@ -677,8 +770,11 @@
"searchAccount": "البحث عن الحسابات...",
"searchMailbox": "البحث عن صناديق البريد...",
"selectAccount": "اختر حسابًا",
"selectAccountAndFiles": "يرجى تحديد الحساب المستهدف والملفات أولاً.",
"selectAccountFirst": "يرجى اختيار حساب أولاً.",
"selectAccountRequired": "يرجى تحديد الحساب المستهدف أولاً.",
"selectFileFirst": "يرجى اختيار ملف أولاً لتحديد الخيارات المتاحة.",
"selectFilesRequired": "يرجى تحديد الملفات للاستيراد.",
"selectMailbox": "اختر صندوق بريد...",
"source": "المصدر",
"startImport": "استيراد",
@@ -689,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": "المرفقات",
@@ -778,10 +914,12 @@
"accounts": "الحسابات",
"apiDocs": "توثيق واجهة برمجة التطبيقات",
"attachment": "المرفقات",
"auditLog": "سجل التدقيق",
"auth": "المصادقة",
"dashboard": "لوحة التحكم",
"general": "عام",
"home": "الرئيسية",
"license": "الترخيص",
"mailbox": "صندوق البريد",
"oauth2": "OAuth2",
"other": "أخرى",
@@ -1409,6 +1547,10 @@
}
},
"proxy": "وكيل",
"proxyTest": "اختبار الوكيل",
"proxyTestFailed": "فشل اتصال الوكيل.",
"proxyTestSuccess": "تم اتصال الوكيل بنجاح!",
"proxyTesting": "جاري اختبار الوكيل...",
"proxyUpdateOrAddFailed": "فشل {{action}}، يرجى المحاولة مرة أخرى لاحقًا",
"reset": "إعادة تعيين",
"resetRootPassword": "إعادة تعيين كلمة مرور الجذر",
@@ -1446,7 +1588,11 @@
},
"sign_out": {
"confirm": "تسجيل الخروج",
"confirm_sso": "تسجيل الخروج من Bichon فقط",
"desc": "هل أنت متأكد أنك تريد تسجيل الخروج؟ ستحتاج إلى تسجيل الدخول مرة أخرى للوصول إلى حسابك.",
"full_sign_out": "تسجيل الخروج وإنهاء جلسة SSO",
"sso_desc": "الخروج من Bichon يُبقي جلسة SSO نشطة. للأمان الكامل، اختر \"الخروج وإنهاء جلسة SSO\".",
"sso_warning": "سيؤدي هذا إلى إنهاء جلسة SSO والخروج من جميع التطبيقات المرتبطة بها.",
"title": "تسجيل الخروج"
},
"system": {

View File

@@ -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",
@@ -240,6 +242,7 @@
"imapPortPlaceholder": "f.eks. 993",
"imapProxy": "Brug en SOCKS5-proxy til IMAP-forbindelser.",
"incDownload": "Interval",
"incSync": "Synk-interval",
"lastSync": "Sidste synk.",
"leaveEmptyToKeepExisting": "Lad stå tomt for at beholde den eksisterende adgangskode, eller indtast en ny for at opdatere den.",
"leaveEmptyToKeepPassword": "Lad stå tomt for at beholde nuværende adgangskode",
@@ -271,6 +274,7 @@
"refreshToken": "Opdateringstoken",
"refreshTokenCopiedToClipboard": "Opdateringstoken kopieret til udklipsholderen",
"relative": "Relativ",
"runGapFill": "Tjek for nye e-mails og fyld automatisk op på ældre manglende e-mails",
"runningState": {
"account": {
"id": "Konto-ID"
@@ -283,24 +287,34 @@
"no_active_download": "Ingen aktiv download",
"no_errors_current": "Ingen fejl i nuværende session",
"no_errors_session": "Ingen fejl i denne session",
"no_gap_fill_folders": "Ingen mapper med manglende e-mails",
"no_gap_fill_history": "Ingen historik over backfill",
"no_global_errors": "Ingen globale fejl",
"no_history": "Ingen historik"
},
"folders": "postkasser",
"gap_fill_active": "Kørende backfill-kørsel",
"gap_fill_downloaded_suffix": "downloadet",
"gap_fill_failed_suffix": "misllykkedes",
"latest": "SENESTE",
"loading": {
"fetching_account_state": "Henter kontostatus..."
},
"message": "Besked",
"session": {
"current_folder": "Nuværende postmappe",
"elapsed": "Varighed",
"last_update": "Senest opdateret",
"started_at": "Starttid",
"status": "Status",
"trigger": "Trigger"
"trigger": "Udløsermetode"
},
"syncing": "Synkroniserer",
"tabs": {
"active_session": "Aktiv session",
"errors": "Fejl",
"folders": "Postkasser",
"gap_fill": "Udfyld manglende e-mails",
"history": "Historik"
}
},
@@ -321,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",
@@ -341,6 +363,7 @@
"sinceRelativeDesc": "Download kun e-mails fra den seneste periode (f.eks. de seneste 3 måneder). Startdatoen flyttes automatisk fremad.",
"sinceRelativeValue": "Download e-mails fra de sidste",
"startDownload": "Start download",
"startDownloadConfirmDesc": "Start download for valgte konti?",
"state": "Tilstand",
"status": "Status",
"step": "Trin {{index}}",
@@ -457,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}})",
@@ -474,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.",
@@ -484,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."
@@ -644,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",
@@ -667,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",
@@ -677,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",
@@ -689,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",
@@ -778,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",
@@ -1409,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",
@@ -1446,7 +1588,11 @@
},
"sign_out": {
"confirm": "Log ud",
"confirm_sso": "Log kun ud af Bichon",
"desc": "Er du sikker på, at du vil logge ud? Du skal logge ind igen for at få adgang til din konto.",
"full_sign_out": "Log ud og afslut SSO-session",
"sso_desc": "Udlogning fra Bichon beholder SSO-sessionen aktiv. For fuld sikkerhed, vælg \"Log ud og afslut SSO-session\".",
"sso_warning": "Dette vil afslutte din SSO-session og logge dig ud af alle tilknyttede apps.",
"title": "Log ud"
},
"system": {

View File

@@ -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",
@@ -240,6 +242,7 @@
"imapPortPlaceholder": "z.B. 993",
"imapProxy": "SOCKS5-Proxy für IMAP-Verbindungen verwenden.",
"incDownload": "Intervall",
"incSync": "Synch.-Intervall",
"lastSync": "Letzte Synchronisierung",
"leaveEmptyToKeepExisting": "Leer lassen, um das bestehende Passwort beizubehalten, oder einen neuen Wert eingeben, um es zu aktualisieren.",
"leaveEmptyToKeepPassword": "Leer lassen, um das aktuelle Passwort beizubehalten",
@@ -271,6 +274,7 @@
"refreshToken": "Aktualisierungstoken",
"refreshTokenCopiedToClipboard": "Aktualisierungstoken in die Zwischenablage kopiert",
"relative": "Relativ",
"runGapFill": "Neue Mails prüfen und ältere, lokal fehlende Mails automatisch nachladen",
"runningState": {
"account": {
"id": "Konto-ID"
@@ -283,24 +287,34 @@
"no_active_download": "Kein aktiver Download",
"no_errors_current": "Keine Fehler in der aktuellen Sitzung",
"no_errors_session": "Keine Fehler in dieser Sitzung",
"no_gap_fill_folders": "Keine Ordner für den Abgleich fehlender Mails",
"no_gap_fill_history": "Kein Backfill-Verlauf vorhanden",
"no_global_errors": "Keine globalen Fehler",
"no_history": "Kein Verlauf vorhanden"
},
"folders": "Postfächer",
"gap_fill_active": "Laufender Backfill-Prozess",
"gap_fill_downloaded_suffix": "heruntergeladen",
"gap_fill_failed_suffix": "fehlgeschlagen",
"latest": "NEU",
"loading": {
"fetching_account_state": "Kontostatus wird geladen..."
},
"message": "Nachricht",
"session": {
"current_folder": "Aktueller Ordner",
"elapsed": "Dauer",
"last_update": "Zuletzt aktualisiert",
"started_at": "Startzeit",
"status": "Status",
"trigger": "Auslöser"
},
"syncing": "Synchronisieren...",
"tabs": {
"active_session": "Aktive Sitzung",
"errors": "Fehler",
"folders": "Postfächer",
"gap_fill": "Lückenfüllung",
"history": "Verlauf"
}
},
@@ -321,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",
@@ -341,6 +363,7 @@
"sinceRelativeDesc": "Nur E-Mails aus dem jüngsten Zeitraum herunterladen (z. B. letzte 3 Monate). Das Startdatum verschiebt sich automatisch.",
"sinceRelativeValue": "E-Mails der letzten Zeit herunterladen",
"startDownload": "Download starten",
"startDownloadConfirmDesc": "E-Mail-Download für gewählte Konten starten?",
"state": "Zustand",
"status": "Status",
"step": "Schritt {{index}}",
@@ -457,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}})",
@@ -474,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.",
@@ -484,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."
@@ -644,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",
@@ -667,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",
@@ -677,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",
@@ -689,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",
@@ -778,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",
@@ -1409,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",
@@ -1446,7 +1588,11 @@
},
"sign_out": {
"confirm": "Abmelden",
"confirm_sso": "Nur bei Bichon abmelden",
"desc": "Möchten Sie sich wirklich abmelden? Sie müssen sich erneut anmelden, um auf Ihr Konto zuzugreifen.",
"full_sign_out": "Abmelden und SSO-Sitzung beenden",
"sso_desc": "Abmelden bei Bichon lässt die SSO-Sitzung aktiv. Für volle Sicherheit \"Abmelden und SSO-Sitzung beenden\" wählen.",
"sso_warning": "Dies beendet Ihre SSO-Sitzung und meldet Sie von allen verknüpften Apps ab.",
"title": "Abmelden"
},
"system": {

View File

@@ -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",
@@ -242,6 +242,7 @@
"imapPortPlaceholder": "e.g 993",
"imapProxy": "Use a proxy (http/socks5) for IMAP connections.",
"incDownload": "Interval",
"incSync": "Sync interval",
"lastSync": "Last Sync",
"leaveEmptyToKeepExisting": "Leave empty to keep the existing password, or enter a new password to update it.",
"leaveEmptyToKeepPassword": "Leave empty to keep current password",
@@ -273,6 +274,7 @@
"refreshToken": "Refresh Token",
"refreshTokenCopiedToClipboard": "Refresh token copied to clipboard",
"relative": "Relative",
"runGapFill": "Check new emails and automatically backfill older missing emails",
"runningState": {
"account": {
"id": "Account ID"
@@ -285,24 +287,34 @@
"no_active_download": "No download in progress",
"no_errors_current": "No errors in current session",
"no_errors_session": "No errors in this session",
"no_gap_fill_folders": "No folders syncing missing messages",
"no_gap_fill_history": "No backfill history",
"no_global_errors": "No global errors",
"no_history": "No historical records found"
},
"folders": "mailboxes",
"gap_fill_active": "Running backfill task",
"gap_fill_downloaded_suffix": "downloaded",
"gap_fill_failed_suffix": "failed",
"latest": "LATEST",
"loading": {
"fetching_account_state": "Fetching account state..."
},
"message": "Message",
"session": {
"current_folder": "Current mail folder",
"elapsed": "Elapsed time",
"last_update": "Last updated",
"started_at": "Started At",
"status": "Status",
"trigger": "Trigger"
},
"syncing": "Syncing",
"tabs": {
"active_session": "Active Session",
"errors": "Errors",
"folders": "Mailboxes",
"gap_fill": "Backfill missing emails",
"history": "History"
}
},
@@ -323,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",
@@ -351,6 +363,7 @@
"sinceRelativeDesc": "Only download emails from the recent period (e.g. last 3 months). The start date automatically moves forward over time.",
"sinceRelativeValue": "Download emails from the last",
"startDownload": "Start download",
"startDownloadConfirmDesc": "Start downloading mail data for selected accounts?",
"state": "State",
"status": "Status",
"step": "Step {{index}}",
@@ -467,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}})",
@@ -485,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.",
@@ -495,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."
@@ -656,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",
@@ -679,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",
@@ -689,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",
@@ -701,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",
@@ -790,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",
@@ -1421,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",
@@ -1462,7 +1588,11 @@
},
"sign_out": {
"confirm": "Sign out",
"confirm_sso": "Sign out of Bichon only",
"desc": "Are you sure you want to sign out? You will need to sign in again to access your account.",
"full_sign_out": "Sign out and end SSO session",
"sso_desc": "Signing out of Bichon leaves your SSO session active. For full security, select \"Sign out and end SSO session\".",
"sso_warning": "This will end your SSO session and sign you out of all connected apps.",
"title": "Sign out"
},
"system": {

View File

@@ -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",
@@ -240,6 +242,7 @@
"imapPortPlaceholder": "ej. 993",
"imapProxy": "Usar proxy SOCKS5 para conexiones IMAP.",
"incDownload": "Intervalo",
"incSync": "Intervalo de sinc.",
"lastSync": "Última sincronización",
"leaveEmptyToKeepExisting": "Deja vacío para mantener la contraseña existente, o introduce un nuevo valor para actualizarla.",
"leaveEmptyToKeepPassword": "Deja vacío para mantener la contraseña actual",
@@ -271,6 +274,7 @@
"refreshToken": "Token de actualización",
"refreshTokenCopiedToClipboard": "Token de actualización copiado al portapapeles",
"relative": "Relativa",
"runGapFill": "Verificar correos nuevos y rellenar automáticamente los faltantes antiguos",
"runningState": {
"account": {
"id": "ID de cuenta"
@@ -283,24 +287,34 @@
"no_active_download": "No hay descargas en curso",
"no_errors_current": "Sin errores en la sesión actual",
"no_errors_session": "Sin errores en esta sesión",
"no_gap_fill_folders": "No hay carpetas sincronizando mensajes faltantes",
"no_gap_fill_history": "Sin historial de backfill",
"no_global_errors": "Sin errores globales",
"no_history": "Sin historial"
},
"folders": "buzones",
"gap_fill_active": "Tarea de backfill en ejecución",
"gap_fill_downloaded_suffix": "descargados",
"gap_fill_failed_suffix": "fallidos",
"latest": "RECIENTE",
"loading": {
"fetching_account_state": "Obteniendo estado de la cuenta..."
},
"message": "Mensaje",
"session": {
"current_folder": "Carpeta de correo actual",
"elapsed": "Tiempo transcurrido",
"last_update": "Última actualización",
"started_at": "Hora de inicio",
"status": "Estado",
"trigger": "Disparador"
},
"syncing": "Sincronizando",
"tabs": {
"active_session": "Sesión activa",
"errors": "Errores",
"folders": "Buzones",
"gap_fill": "Completar mensajes faltantes",
"history": "Historial"
}
},
@@ -321,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",
@@ -341,6 +363,7 @@
"sinceRelativeDesc": "Solo descargar correos del período reciente (ej. últimos 3 meses). La fecha de inicio avanza automáticamente.",
"sinceRelativeValue": "Descargar correos de los últimos",
"startDownload": "Iniciar descarga",
"startDownloadConfirmDesc": "¿Iniciar descarga para las cuentas seleccionadas?",
"state": "Estado",
"status": "Estado",
"step": "Paso {{index}}",
@@ -457,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}})",
@@ -474,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.",
@@ -484,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."
@@ -644,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",
@@ -667,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",
@@ -677,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",
@@ -689,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",
@@ -778,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",
@@ -1409,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",
@@ -1446,7 +1588,11 @@
},
"sign_out": {
"confirm": "Cerrar sesión",
"confirm_sso": "Solo cerrar sesión en Bichon",
"desc": "¿Está seguro de que desea cerrar sesión? Necesitará iniciar sesión nuevamente para acceder a su cuenta.",
"full_sign_out": "Cerrar sesión y finalizar sesión SSO",
"sso_desc": "Cerrar sesión en Bichon mantiene activa la sesión SSO. Para mayor seguridad, elija \"Cerrar sesión y finalizar sesión SSO\".",
"sso_warning": "Esto finalizará su sesión SSO y cerrará la sesión en todas las aplicaciones vinculadas.",
"title": "Cerrar sesión"
},
"system": {

View File

@@ -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",
@@ -240,6 +242,7 @@
"imapPortPlaceholder": "esim. 993",
"imapProxy": "Käytä SOCKS5-välityspalvelinta IMAP-yhteyksiin.",
"incDownload": "Väli",
"incSync": "Synkronointiväli",
"lastSync": "Viimeisin synkronointi",
"leaveEmptyToKeepExisting": "Jätä tyhjäksi säilyttääksesi olemassa olevan salasanan, tai syötä uusi päivittääksesi sen.",
"leaveEmptyToKeepPassword": "Jätä tyhjäksi säilyttääksesi nykyisen salasanan",
@@ -271,6 +274,7 @@
"refreshToken": "Virheettömyystunnus",
"refreshTokenCopiedToClipboard": "Virheettömyystunnus kopioitu leikepöydälle",
"relative": "Suhteellinen",
"runGapFill": "Tarkista uudet sähköpostit ja täydennä vanhat puuttuvat viestit automaattisesti",
"runningState": {
"account": {
"id": "Tilin ID"
@@ -283,24 +287,34 @@
"no_active_download": "Ei aktiivista latausta",
"no_errors_current": "Ei virheitä nykyisessä istunnossa",
"no_errors_session": "Ei virheitä tässä istunnossa",
"no_gap_fill_folders": "Ei kansioita puuttuvien viestien täydennykseen",
"no_gap_fill_history": "Ei täydennyshistoriaa",
"no_global_errors": "Ei yleisiä virheitä",
"no_history": "Ei historiaa"
},
"folders": "postilaatikot",
"gap_fill_active": "Käynnissä oleva täydennys",
"gap_fill_downloaded_suffix": "ladattu",
"gap_fill_failed_suffix": "epäonnistui",
"latest": "UUSIN",
"loading": {
"fetching_account_state": "Haetaan tilan tietoja..."
},
"message": "Viesti",
"session": {
"current_folder": "Nykyinen postikansio",
"elapsed": "Kesto",
"last_update": "Viimeksi päivitetty",
"started_at": "Aloitusaika",
"status": "Tila",
"trigger": "Laukaisija"
"trigger": "Käynnistystapa"
},
"syncing": "Synkronoidaan",
"tabs": {
"active_session": "Aktiivinen istunto",
"errors": "Virheet",
"folders": "Postilaatikot",
"gap_fill": "Puuttuvien täydennys",
"history": "Historia"
}
},
@@ -321,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",
@@ -341,6 +363,7 @@
"sinceRelativeDesc": "Lataa vain viimeaikaiset sähköpostit (esim. viimeiset 3 kuukautta). Aloituspäivämäärä siirtyy automaattisesti eteenpäin.",
"sinceRelativeValue": "Lataa sähköpostit viimeisimmiltä",
"startDownload": "Aloita lataus",
"startDownloadConfirmDesc": "Aloitetaanko valittujen tilien lataus?",
"state": "Tila",
"status": "Tila",
"step": "Vaihe {{index}}",
@@ -457,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}})",
@@ -474,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.",
@@ -484,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."
@@ -644,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",
@@ -667,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",
@@ -677,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",
@@ -689,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",
@@ -778,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",
@@ -1409,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",
@@ -1446,7 +1588,11 @@
},
"sign_out": {
"confirm": "Kirjaudu ulos",
"confirm_sso": "Kirjaudu ulos vain Bichonista",
"desc": "Haluatko varmasti kirjautua ulos? Sinun täytyy kirjautua uudelleen päästäksesi tilillesi.",
"full_sign_out": "Kirjaudu ulos ja lopeta SSO-istunto",
"sso_desc": "Uloskirjautuminen Bichonista jättää SSO-istunnon aktiiviseksi. Täyden turvallisuuden saamiseksi valitse \"Kirjaudu ulos ja lopeta SSO-istunto\".",
"sso_warning": "Tämä lopettaa SSO-istunnon ja kirjautuu ulos kaikista liitetyistä sovelluksista.",
"title": "Kirjaudu ulos"
},
"system": {

View File

@@ -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",
@@ -240,6 +242,7 @@
"imapPortPlaceholder": "ex. 993",
"imapProxy": "Utiliser un proxy SOCKS5 pour les connexions IMAP.",
"incDownload": "Intervalle",
"incSync": "Intervalle de synchro",
"lastSync": "Dernière Synchronisation",
"leaveEmptyToKeepExisting": "Laissez vide pour conserver le mot de passe existant, ou entrez-en un nouveau pour le mettre à jour.",
"leaveEmptyToKeepPassword": "Laisser vide pour conserver le mot de passe actuel",
@@ -271,6 +274,7 @@
"refreshToken": "Jeton de Rafraîchissement",
"refreshTokenCopiedToClipboard": "Jeton de rafraîchissement copié dans le presse-papiers",
"relative": "Relative",
"runGapFill": "Vérifier les nouveaux e-mails et rattraper automatiquement les anciens messages manquants",
"runningState": {
"account": {
"id": "ID du compte"
@@ -283,24 +287,34 @@
"no_active_download": "Aucun téléchargement en cours",
"no_errors_current": "Aucune erreur dans la session actuelle",
"no_errors_session": "Aucune erreur dans cette session",
"no_gap_fill_folders": "Aucun dossier en cours de synchronisation des messages manquants",
"no_gap_fill_history": "Aucun historique de rattrapage",
"no_global_errors": "Aucune erreur globale",
"no_history": "Aucun historique disponible"
},
"folders": "boîtes mail",
"gap_fill_active": "Tâche de rattrapage en cours",
"gap_fill_downloaded_suffix": "téléchargés",
"gap_fill_failed_suffix": "échec(s)",
"latest": "RÉCENT",
"loading": {
"fetching_account_state": "Chargement de l'état du compte..."
},
"message": "Message",
"session": {
"current_folder": "Dossier de courrier actuel",
"elapsed": "Temps écoulé",
"last_update": "Dernière mise à jour",
"started_at": "Heure de début",
"status": "Statut",
"trigger": "Déclencheur"
},
"syncing": "Synchronisation",
"tabs": {
"active_session": "Session active",
"errors": "Erreurs",
"folders": "Boîtes mail",
"gap_fill": "Rattrapage des messages",
"history": "Historique"
}
},
@@ -321,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",
@@ -341,6 +363,7 @@
"sinceRelativeDesc": "Télécharger uniquement les e-mails récents (ex. 3 derniers mois). La date de début avance automatiquement.",
"sinceRelativeValue": "Télécharger les e-mails des derniers",
"startDownload": "Lancer le téléchargement",
"startDownloadConfirmDesc": "Démarrer le téléchargement pour les comptes sélectionnés ?",
"state": "État",
"status": "Statut",
"step": "Étape {{index}}",
@@ -457,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}})",
@@ -474,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.",
@@ -484,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."
@@ -644,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é",
@@ -667,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",
@@ -677,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",
@@ -689,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",
@@ -778,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",
@@ -1409,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",
@@ -1446,7 +1588,11 @@
},
"sign_out": {
"confirm": "Déconnexion",
"confirm_sso": "Se déconnecter de Bichon uniquement",
"desc": "Êtes-vous sûr de vouloir vous déconnecter ? Vous devrez vous reconnecter pour accéder à votre compte.",
"full_sign_out": "Se déconnecter et mettre fin à la session SSO",
"sso_desc": "Mettre fin à Bichon laisse la session SSO active. Pour une sécurité totale, choisissez « Se déconnecter et mettre fin à la session SSO ».",
"sso_warning": "Ceci mettra fin à votre session SSO et vous déconnectera de toutes les applications associées.",
"title": "Déconnexion"
},
"system": {

View File

@@ -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",
@@ -240,6 +242,7 @@
"imapPortPlaceholder": "es. 993",
"imapProxy": "Usa un proxy SOCKS5 per le connessioni IMAP.",
"incDownload": "Intervallo",
"incSync": "Intervallo sinc.",
"lastSync": "Ultima Sincronizzazione",
"leaveEmptyToKeepExisting": "Lascia vuoto per mantenere la password esistente, o inseriscine una nuova per aggiornarla.",
"leaveEmptyToKeepPassword": "Lascia vuoto per mantenere la password attuale",
@@ -271,6 +274,7 @@
"refreshToken": "Token di Refresh",
"refreshTokenCopiedToClipboard": "Token di refresh copiato negli appunti",
"relative": "Relativa",
"runGapFill": "Controlla le nuove email e recupera automaticamente i vecchi messaggi mancanti",
"runningState": {
"account": {
"id": "ID account"
@@ -283,24 +287,34 @@
"no_active_download": "Nessun download in corso",
"no_errors_current": "Nessun errore nella sessione corrente",
"no_errors_session": "Nessun errore in questa sessione",
"no_gap_fill_folders": "Nessuna cartella con messaggi mancanti da scaricare",
"no_gap_fill_history": "Nessuna cronologia di backfill",
"no_global_errors": "Nessun errore globale",
"no_history": "Nessuna cronologia disponibile"
},
"folders": "caselle di posta",
"gap_fill_active": "Attività di backfill in esecuzione",
"gap_fill_downloaded_suffix": "scaricati",
"gap_fill_failed_suffix": "non riusciti",
"latest": "RECENTE",
"loading": {
"fetching_account_state": "Caricamento stato account..."
},
"message": "Messaggio",
"session": {
"current_folder": "Cartella posta corrente",
"elapsed": "Tempo trascorso",
"last_update": "Ultimo aggiornamento",
"started_at": "Ora di inizio",
"status": "Stato",
"trigger": "Trigger"
"trigger": "Innesco"
},
"syncing": "Sincronizzazione in corso",
"tabs": {
"active_session": "Sessione attiva",
"errors": "Errori",
"folders": "Caselle di posta",
"gap_fill": "Recupero messaggi mancanti",
"history": "Cronologia"
}
},
@@ -321,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",
@@ -341,6 +363,7 @@
"sinceRelativeDesc": "Scarica solo le email del periodo recente (es. ultimi 3 mesi). La data di inizio si aggiorna automaticamente.",
"sinceRelativeValue": "Scarica email degli ultimi",
"startDownload": "Avvia download",
"startDownloadConfirmDesc": "Avviare il download per gli account selezionati?",
"state": "Stato",
"status": "Stato",
"step": "Passo {{index}}",
@@ -457,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}})",
@@ -474,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.",
@@ -484,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."
@@ -644,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",
@@ -667,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",
@@ -677,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",
@@ -689,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",
@@ -778,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",
@@ -1409,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",
@@ -1446,7 +1588,11 @@
},
"sign_out": {
"confirm": "Disconnetti",
"confirm_sso": "Disconnettiti solo da Bichon",
"desc": "Sei sicuro di voler disconnetterti? Dovrai accedere di nuovo per usare il tuo account.",
"full_sign_out": "Disconnettiti e termina sessione SSO",
"sso_desc": "La disconnessione da Bichon lascia attiva la sessione SSO. Per la massima sicurezza, scegli \"Disconnettiti e termina sessione SSO\".",
"sso_warning": "Questo terminerà la sessione SSO e ti disconnetterà da tutte le app collegate.",
"title": "Disconnetti"
},
"system": {

View File

@@ -98,6 +98,7 @@
"allMailSkipped": "標準フォルダーが選択されました。「すべてのメール」は重複を避けるためにスキップされました。",
"areYouSureYouWantTo": "本当にこのアカウントを{{action}}しますか?",
"auth": "認証",
"authPassword": "認証パスワード",
"authType": "認証タイプ",
"autoConfiguring": "自動設定中…",
"autoDiscover": "サーバー設定の自動検出",
@@ -116,6 +117,7 @@
"clickSaveWhenDone": "完了したら「保存」をクリックしてください。",
"continue": "続行",
"createdAt": "作成日時",
"creating": "アカウントを作成中...",
"creationFailed": "作成に失敗しました。しばらくしてからもう一度お試しください。",
"cronAdvanced": "高度な式",
"cronDaily": "毎日",
@@ -240,6 +242,7 @@
"imapPortPlaceholder": "例: 993",
"imapProxy": "IMAP接続にSOCKS5プロキシを使用します。",
"incDownload": "間隔",
"incSync": "同期間隔",
"lastSync": "最終同期",
"leaveEmptyToKeepExisting": "既存のパスワードを保持する場合は空欄にしてください。更新する場合は新しいパスワードを入力してください。",
"leaveEmptyToKeepPassword": "現在のパスワードを保持する場合は空欄にしてください",
@@ -271,6 +274,7 @@
"refreshToken": "リフレッシュトークン",
"refreshTokenCopiedToClipboard": "リフレッシュトークンをクリップボードにコピーしました",
"relative": "相対",
"runGapFill": "新着メールを確認してダウンロードし、過去の未取得メールも自動的に差分補填します",
"runningState": {
"account": {
"id": "アカウントID"
@@ -283,24 +287,34 @@
"no_active_download": "現在ダウンロード中のタスクはありません",
"no_errors_current": "現在のタスクにエラーはありません",
"no_errors_session": "このタスクにエラーはありません",
"no_gap_fill_folders": "未取得メールを同期中のフォルダーはありません",
"no_gap_fill_history": "差分補填の履歴はありません",
"no_global_errors": "全体エラーはありません",
"no_history": "履歴がありません"
},
"folders": "メールボックス",
"gap_fill_active": "実行中の差分補填タスク",
"gap_fill_downloaded_suffix": "件ダウンロード済み",
"gap_fill_failed_suffix": "件失敗",
"latest": "最新",
"loading": {
"fetching_account_state": "アカウント状態を取得中..."
},
"message": "メッセージ",
"session": {
"current_folder": "現在のメールフォルダー",
"elapsed": "経過時間",
"last_update": "最終更新",
"started_at": "開始時刻",
"status": "状態",
"trigger": "トリガー"
},
"syncing": "同期中",
"tabs": {
"active_session": "実行中タスク",
"errors": "エラー",
"folders": "メールボックス",
"gap_fill": "差分メール補填",
"history": "履歴"
}
},
@@ -321,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": "指定した日付以降",
@@ -341,6 +363,7 @@
"sinceRelativeDesc": "直近の期間過去3ヶ月のメールのみをダウンロードします。開始日は時間経過に伴い自動的に更新されます。",
"sinceRelativeValue": "直近の期間のメールをダウンロード",
"startDownload": "ダウンロードを開始",
"startDownloadConfirmDesc": "選択したアカウントのメールデータをダウンロードしますか?",
"state": "状態",
"status": "ステータス",
"step": "ステップ {{index}}",
@@ -457,6 +480,7 @@
"downloading": "ダウンロード中...",
"emailMessageNotFound": "元のメールが見つかりません。削除された可能性があります。",
"name": "ファイル名",
"preview": "添付ファイルをプレビュー",
"search_input_placeholder": "添付ファイルを検索 (フレーズ検索は \" \" を使用)",
"sender": "送信者",
"sender_with_count": "送信者 ({{count}})",
@@ -474,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": "パスワードが無効です。もう一度お試しください。",
@@ -484,6 +572,7 @@
"sessionExpired": "セッションの有効期限が切れました!",
"sessionExpiredDesc": "操作がないためセッションが終了しました。継続するには再度ログインしてください。",
"somethingWentWrong": "問題が発生しました",
"ssoLogin": "SSO ログイン",
"username": "ユーザー名",
"welcome": "Bichon へようこそ",
"youWillNeedToLogInAgain": "アカウントにアクセスするには、再度ログインする必要があります。"
@@ -644,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": "カスタムフォルダ名を入力",
@@ -667,6 +759,7 @@
"modeHeaderDesc": "ファイルから X-Gmail-Labels / X-Bichon-Metadata を読み取ります。ない場合はファイル名を使用します。",
"noAccountFound": "アカウントが見つかりません。",
"noFileYet": "ファイルが選択されていません",
"noFilesSelected": "ファイルが選択されていません",
"noMailboxFound": "メールボックスが見つかりません。",
"noMailboxes": "このアカウントにメールボックスが見つかりません。",
"orClick": "またはクリックしてファイルを選択",
@@ -677,8 +770,11 @@
"searchAccount": "アカウントを検索...",
"searchMailbox": "メールボックスを検索...",
"selectAccount": "アカウントを選択",
"selectAccountAndFiles": "最初に対象のアカウントとファイルを選択してください。",
"selectAccountFirst": "最初にアカウントを選択してください。",
"selectAccountRequired": "最初に対象のアカウントを選択してください。",
"selectFileFirst": "利用可能なオプションを確認するには、最初にファイルを選択してください。",
"selectFilesRequired": "インポートするファイルを選択してください。",
"selectMailbox": "メールボックスを選択...",
"source": "ソース",
"startImport": "インポート",
@@ -689,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": "添付ファイル",
@@ -778,10 +914,12 @@
"accounts": "アカウント",
"apiDocs": "APIドキュメント",
"attachment": "添付ファイル",
"auditLog": "監査ログ",
"auth": "認証",
"dashboard": "ダッシュボード",
"general": "一般",
"home": "ホーム",
"license": "ライセンス",
"mailbox": "メールボックス",
"oauth2": "OAuth2",
"other": "その他",
@@ -1409,6 +1547,10 @@
}
},
"proxy": "プロキシ",
"proxyTest": "プロキシをテスト",
"proxyTestFailed": "プロキシ接続に失敗しました。",
"proxyTestSuccess": "プロキシ接続に成功しました!",
"proxyTesting": "プロキシをテスト中...",
"proxyUpdateOrAddFailed": "{{action}}に失敗しました。しばらくしてからもう一度お試しください",
"reset": "リセット",
"resetRootPassword": "ルートパスワードをリセット",
@@ -1446,7 +1588,11 @@
},
"sign_out": {
"confirm": "サインアウト",
"confirm_sso": "Bichon のみログアウト",
"desc": "本当にサインアウトしますか? アカウントにアクセスするには再度サインインが必要です。",
"full_sign_out": "ログアウトして SSO セッションを終了",
"sso_desc": "Bichon からログアウトしても SSO は有効なままです。完全に終了するには「ログアウトして SSO セッションを終了」を選択してください。",
"sso_warning": "SSO セッションが終了し、連携しているすべてのアプリからログアウトします。",
"title": "サインアウト"
},
"system": {

View File

@@ -98,6 +98,7 @@
"allMailSkipped": "기본 폴더가 선택되었습니다. 중복을 방지하기 위해 '모든 메일'은 건너뛰었습니다.",
"areYouSureYouWantTo": "이 계정을 정말 {{action}}하시겠습니까?",
"auth": "인증",
"authPassword": "인증 비밀번호",
"authType": "인증 유형",
"autoConfiguring": "자동 설정 중…",
"autoDiscover": "서버 설정 자동 검색",
@@ -116,6 +117,7 @@
"clickSaveWhenDone": "완료되면 '저장'을 클릭하십시오.",
"continue": "계속",
"createdAt": "생성일",
"creating": "계정 생성 중...",
"creationFailed": "생성에 실패했습니다. 나중에 다시 시도하십시오.",
"cronAdvanced": "고급 표현식",
"cronDaily": "매일",
@@ -240,6 +242,7 @@
"imapPortPlaceholder": "예: 993",
"imapProxy": "IMAP 연결에 SOCKS5 프록시를 사용합니다.",
"incDownload": "간격",
"incSync": "동기화 간격",
"lastSync": "최종 동기화",
"leaveEmptyToKeepExisting": "기존 비밀번호를 유지하려면 비워 두십시오. 업데이트할 경우에만 새 비밀번호를 입력하십시오.",
"leaveEmptyToKeepPassword": "현재 비밀번호를 유지하려면 비워 두십시오",
@@ -271,6 +274,7 @@
"refreshToken": "새로 고침 토큰",
"refreshTokenCopiedToClipboard": "새로 고침 토큰이 클립보드에 복사되었습니다",
"relative": "상대적",
"runGapFill": "새 메일을 확인하여 다운로드하고, 누락된 과거 메일도 자동으로 백필합니다",
"runningState": {
"account": {
"id": "계정 ID"
@@ -283,24 +287,34 @@
"no_active_download": "진행 중인 다운로드가 없습니다",
"no_errors_current": "현재 작업에 오류가 없습니다",
"no_errors_session": "이 작업에 오류가 없습니다",
"no_gap_fill_folders": "누락된 메일을 동기화 중인 메일함이 없습니다",
"no_gap_fill_history": "백필 기록 없음",
"no_global_errors": "전체 오류가 없습니다",
"no_history": "기록이 없습니다"
},
"folders": "메일함",
"gap_fill_active": "실행 중인 백필 작업",
"gap_fill_downloaded_suffix": "개 다운로드됨",
"gap_fill_failed_suffix": "개 실패",
"latest": "최신",
"loading": {
"fetching_account_state": "계정 상태를 불러오는 중..."
},
"message": "메시지",
"session": {
"current_folder": "현재 메일함",
"elapsed": "경과 시간",
"last_update": "최근 업데이트",
"started_at": "시작 시간",
"status": "상태",
"trigger": "트리거"
},
"syncing": "동기화 중",
"tabs": {
"active_session": "현재 작업",
"errors": "오류",
"folders": "메일함",
"gap_fill": "누락 메일 백필",
"history": "기록"
}
},
@@ -321,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": "특정 날짜 이후",
@@ -341,6 +363,7 @@
"sinceRelativeDesc": "최근 기간(예: 지난 3개월)의 이메일만 다운로드합니다. 시작 날짜는 시간이 지남에 따라 자동으로 이동합니다.",
"sinceRelativeValue": "최근 기간의 이메일 다운로드",
"startDownload": "다운로드 시작",
"startDownloadConfirmDesc": "선택한 계정의 메일 데이터를 다운로드할까요?",
"state": "상태",
"status": "상태",
"step": "단계 {{index}}",
@@ -457,6 +480,7 @@
"downloading": "다운로드 중...",
"emailMessageNotFound": "원본 메일을 찾을 수 없습니다. 삭제되었을 수 있습니다.",
"name": "파일 이름",
"preview": "첨부 파일 미리보기",
"search_input_placeholder": "첨부 파일 검색 (구문 검색은 \" \" 사용)",
"sender": "보낸 사람",
"sender_with_count": "보낸 사람 ({{count}})",
@@ -474,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": "비밀번호가 유효하지 않습니다. 다시 시도해 주세요.",
@@ -484,6 +572,7 @@
"sessionExpired": "세션이 만료되었습니다!",
"sessionExpiredDesc": "비활성화로 인해 세션이 종료되었습니다. 계속하려면 다시 로그인하십시오.",
"somethingWentWrong": "문제가 발생했습니다",
"ssoLogin": "SSO 로그인",
"username": "사용자 이름",
"welcome": "Bichon에 오신 것을 환영합니다",
"youWillNeedToLogInAgain": "계정에 접근하려면 다시 로그인해야 합니다."
@@ -644,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": "사용자 지정 폴더 이름 입력",
@@ -667,6 +759,7 @@
"modeHeaderDesc": "파일에서 X-Gmail-Labels / X-Bichon-Metadata를 읽습니다. 없을 경우 파일명을 사용합니다.",
"noAccountFound": "계정을 찾을 수 없습니다.",
"noFileYet": "선택된 파일 없음",
"noFilesSelected": "선택된 파일이 없습니다",
"noMailboxFound": "편지함을 찾을 수 없습니다.",
"noMailboxes": "이 계정에서 편지함을 찾을 수 없습니다.",
"orClick": "또는 클릭하여 찾아보기",
@@ -677,8 +770,11 @@
"searchAccount": "계정 검색...",
"searchMailbox": "편지함 검색...",
"selectAccount": "계정 선택",
"selectAccountAndFiles": "먼저 대상 계정과 파일을 선택해 주세요.",
"selectAccountFirst": "계정을 먼저 선택해 주세요.",
"selectAccountRequired": "먼저 대상 계정을 선택해 주세요.",
"selectFileFirst": "사용 가능한 옵션을 확인하려면 먼저 파일을 선택해 주세요.",
"selectFilesRequired": "가져올 파일을 선택해 주세요.",
"selectMailbox": "편지함 선택...",
"source": "소스",
"startImport": "가져오기",
@@ -689,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": "첨부 파일",
@@ -778,10 +914,12 @@
"accounts": "계정",
"apiDocs": "API 문서",
"attachment": "첨부파일",
"auditLog": "감사 로그",
"auth": "인증",
"dashboard": "대시보드",
"general": "일반",
"home": "홈",
"license": "라이선스",
"mailbox": "받은 편지함",
"oauth2": "OAuth2",
"other": "기타",
@@ -1409,6 +1547,10 @@
}
},
"proxy": "프록시",
"proxyTest": "프록시 테스트",
"proxyTestFailed": "프록시 연결에 실패했습니다.",
"proxyTestSuccess": "프록시 연결에 성공했습니다!",
"proxyTesting": "프록시 테스트 중...",
"proxyUpdateOrAddFailed": "{{action}}에 실패했습니다. 나중에 다시 시도하십시오",
"reset": "초기화",
"resetRootPassword": "루트 비밀번호 초기화",
@@ -1446,7 +1588,11 @@
},
"sign_out": {
"confirm": "로그아웃",
"confirm_sso": "Bichon만 로그아웃",
"desc": "로그아웃하시겠습니까? 계정에 접근하려면 다시 로그인해야 합니다.",
"full_sign_out": "로그아웃 및 SSO 세션 종료",
"sso_desc": "Bichon 로그아웃 후에도 SSO 세션은 유지됩니다. 완전한 보안을 위해 \"로그아웃 및 SSO 세션 종료\"를 선택하세요.",
"sso_warning": "SSO 세션이 종료되며 연결된 모든 앱에서 로그아웃됩니다.",
"title": "로그아웃"
},
"system": {

View File

@@ -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",
@@ -240,6 +242,7 @@
"imapPortPlaceholder": "bv. 993",
"imapProxy": "Gebruik een SOCKS5-proxy voor IMAP-verbindingen.",
"incDownload": "Interval",
"incSync": "Synch.-interval",
"lastSync": "Laatste Sync",
"leaveEmptyToKeepExisting": "Laat leeg om het bestaande wachtwoord te behouden, of voer een nieuw wachtwoord in om het bij te werken.",
"leaveEmptyToKeepPassword": "Laat leeg om huidig wachtwoord te behouden",
@@ -271,6 +274,7 @@
"refreshToken": "Vernieuwingstoken (Refresh Token)",
"refreshTokenCopiedToClipboard": "Vernieuwingstoken naar klembord gekopieerd",
"relative": "Relatief",
"runGapFill": "Controleer op nieuwe e-mails en vul oudere ontbrekende e-mails automatisch aan",
"runningState": {
"account": {
"id": "Account-ID"
@@ -283,24 +287,34 @@
"no_active_download": "Geen actieve download",
"no_errors_current": "Geen fouten in huidige sessie",
"no_errors_session": "Geen fouten in deze sessie",
"no_gap_fill_folders": "Geen mappen die ontbrekende berichten synchroniseren",
"no_gap_fill_history": "Geen backfill-geschiedenis",
"no_global_errors": "Geen globale fouten",
"no_history": "Geen geschiedenis beschikbaar"
},
"folders": "mailboxen",
"gap_fill_active": "Lopende backfill-taak",
"gap_fill_downloaded_suffix": "Gedownload",
"gap_fill_failed_suffix": "mislukt",
"latest": "RECENT",
"loading": {
"fetching_account_state": "Accountstatus ophalen..."
},
"message": "Bericht",
"session": {
"current_folder": "Huidige e-mailmap",
"elapsed": "Verstreken tijd",
"last_update": "Laatst bijgewerkt",
"started_at": "Starttijd",
"status": "Status",
"trigger": "Trigger"
"trigger": "Triggermethode"
},
"syncing": "Synchroniseren",
"tabs": {
"active_session": "Actieve sessie",
"errors": "Fouten",
"folders": "Mailboxen",
"gap_fill": "Ontbrekende berichten aanvullen",
"history": "Geschiedenis"
}
},
@@ -321,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",
@@ -341,6 +363,7 @@
"sinceRelativeDesc": "Download alleen e-mails uit de afgelopen periode (bijv. laatste 3 maanden). De startdatum verschuift automatisch mee.",
"sinceRelativeValue": "Download e-mails van de laatste",
"startDownload": "Download starten",
"startDownloadConfirmDesc": "Download starten voor geselecteerde accounts?",
"state": "Status",
"status": "Status",
"step": "Stap {{index}}",
@@ -457,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}})",
@@ -474,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.",
@@ -484,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."
@@ -644,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",
@@ -667,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",
@@ -677,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",
@@ -689,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",
@@ -778,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",
@@ -1409,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",
@@ -1446,7 +1588,11 @@
},
"sign_out": {
"confirm": "Uitloggen",
"confirm_sso": "Alleen uitloggen bij Bichon",
"desc": "Weet je zeker dat je wilt uitloggen? Je moet opnieuw inloggen om toegang te krijgen tot je account.",
"full_sign_out": "Uitloggen en SSO-sessie beëindigen",
"sso_desc": "Uitloggen bij Bichon laat de SSO-sessie actief. Kies voor volledige beveiliging \"Uitloggen en SSO-sessie beëindigen\".",
"sso_warning": "Dit beëindigt uw SSO-sessie en logt u uit bij alle gekoppelde apps.",
"title": "Uitloggen"
},
"system": {

View File

@@ -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",
@@ -240,6 +242,7 @@
"imapPortPlaceholder": "f.eks. 993",
"imapProxy": "Bruk en SOCKS5-proxy for IMAP-tilkoblinger.",
"incDownload": "Intervall",
"incSync": "Synk-intervall",
"lastSync": "Siste synkronisering",
"leaveEmptyToKeepExisting": "La stå tomt for å beholde det eksisterende passordet, eller skriv inn et nytt passord for å oppdatere det.",
"leaveEmptyToKeepPassword": "La stå tomt for å beholde nåværende passord",
@@ -271,6 +274,7 @@
"refreshToken": "Oppfriskningstoken",
"refreshTokenCopiedToClipboard": "Oppfriskningstoken kopiert til utklippstavlen",
"relative": "Relativ",
"runGapFill": "Sjekk etter nye e-poster og fyll automatisk inn eldre manglende e-poster",
"runningState": {
"account": {
"id": "Konto-ID"
@@ -283,24 +287,34 @@
"no_active_download": "Ingen aktiv nedlasting",
"no_errors_current": "Ingen feil i gjeldende økt",
"no_errors_session": "Ingen feil i denne økten",
"no_gap_fill_folders": "Ingen mapper med manglende e-poster",
"no_gap_fill_history": "Ingen backfill-historikk",
"no_global_errors": "Ingen globale feil",
"no_history": "Ingen historikk"
},
"folders": "postbokser",
"gap_fill_active": "Kjørende backfill-oppgave",
"gap_fill_downloaded_suffix": "lastet ned",
"gap_fill_failed_suffix": "mislyktes",
"latest": "NYEST",
"loading": {
"fetching_account_state": "Henter kontostatus..."
},
"message": "Melding",
"session": {
"current_folder": "Nåværende e-postmappe",
"elapsed": "Varighet",
"last_update": "Sist oppdatert",
"started_at": "Starttid",
"status": "Status",
"trigger": "Trigger"
"trigger": "Utløser"
},
"syncing": "Synkroniserer",
"tabs": {
"active_session": "Aktiv økt",
"errors": "Feil",
"folders": "Postbokser",
"gap_fill": "Fyll inn manglende e-poster",
"history": "Historikk"
}
},
@@ -321,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",
@@ -341,6 +363,7 @@
"sinceRelativeDesc": "Last bare ned e-poster fra den siste perioden (f.eks. siste 3 måneder). Startdatoen flyttes automatisk fremover.",
"sinceRelativeValue": "Last ned e-poster fra de siste",
"startDownload": "Start nedlasting",
"startDownloadConfirmDesc": "Start nedlasting for valgte kontoer?",
"state": "Tilstand",
"status": "Status",
"step": "Trinn {{index}}",
@@ -457,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}})",
@@ -474,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.",
@@ -484,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."
@@ -644,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",
@@ -667,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",
@@ -677,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",
@@ -689,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",
@@ -778,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",
@@ -1409,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",
@@ -1446,7 +1588,11 @@
},
"sign_out": {
"confirm": "Logg ut",
"confirm_sso": "Logg kun ut av Bichon",
"desc": "Er du sikker på at du vil logge ut? Du må logge inn igjen for å få tilgang til kontoen din.",
"full_sign_out": "Logg ut og avslutt SSO-sesjon",
"sso_desc": "Utlogging fra Bichon beholder SSO-sesjonen aktiv. For full sikkerhet, velg \"Logg ut og avslutt SSO-sesjon\".",
"sso_warning": "Dette vil avslutte SSO-sesjonen og logge deg ut av alle tilknyttede apper.",
"title": "Logg ut"
},
"system": {

View File

@@ -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",
@@ -240,6 +242,7 @@
"imapPortPlaceholder": "np. 993 (szyfrowany TLS/SSL) lub 143 (bez szyfrowania)",
"imapProxy": "Użyj proxy gniazda SOCKS5 dla połączeń IMAP.",
"incDownload": "Interwał",
"incSync": "Interwał synch.",
"lastSync": "OStatnia synchronizacja",
"leaveEmptyToKeepExisting": "Pozostaw to pole puste, jeśli chcesz zachować dotychczasowe hasło lub wpisz nowe, aby zaktualizować.",
"leaveEmptyToKeepPassword": "Pozostaw to pole puste, jeśli chcesz zachować dotychczasowe hasło",
@@ -271,6 +274,7 @@
"refreshToken": "Odśwież token",
"refreshTokenCopiedToClipboard": "Token odśwież skopiowany do schowka",
"relative": "Wzglednie",
"runGapFill": "Sprawdzaj nowe e-maile i automatycznie uzupełniaj starsze, brakujące wiadomości",
"runningState": {
"account": {
"id": "ID konta"
@@ -283,24 +287,34 @@
"no_active_download": "Brak aktywnego pobierania",
"no_errors_current": "Brak błędów w bieżącej sesji",
"no_errors_session": "Brak błędów w tej sesji",
"no_gap_fill_folders": "Brak folderów z brakującymi wiadomościami do pobrania",
"no_gap_fill_history": "Brak historii uzupełniania",
"no_global_errors": "Brak błędów globalnych",
"no_history": "Brak historii"
},
"folders": "skrzynki",
"gap_fill_active": "Trwające uzupełnianie",
"gap_fill_downloaded_suffix": "pobrano",
"gap_fill_failed_suffix": "niepowodzenie",
"latest": "NAJNOWSZE",
"loading": {
"fetching_account_state": "Pobieranie stanu konta..."
},
"message": "Wiadomość",
"session": {
"current_folder": "Bieżący folder poczty",
"elapsed": "Czas trwania",
"last_update": "Ostatnia aktualizacja",
"started_at": "Czas rozpoczęcia",
"status": "Status",
"trigger": "Wyzwalacz"
},
"syncing": "Synchronizowanie",
"tabs": {
"active_session": "Aktywna sesja",
"errors": "Błędy",
"folders": "Skrzynki",
"gap_fill": "Uzupełnianie braków",
"history": "Historia"
}
},
@@ -321,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",
@@ -341,6 +363,7 @@
"sinceRelativeDesc": "Pobieraj tylko wiadomości z ostatniego okresu (np. ostatnie 3 miesiące). Data początkowa automatycznie przesuwa się w czasie.",
"sinceRelativeValue": "Pobierz e-maile z ostatnich",
"startDownload": "Uruchom pobieranie",
"startDownloadConfirmDesc": "Rozpocząć pobieranie dla wybranych kont?",
"state": "Status",
"status": "Status",
"step": "Krok {{index}}",
@@ -457,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}})",
@@ -474,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",
@@ -484,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ć."
@@ -644,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",
@@ -667,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ć",
@@ -677,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",
@@ -689,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",
@@ -778,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",
@@ -1409,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",
@@ -1446,7 +1588,11 @@
},
"sign_out": {
"confirm": "Wyloguj się",
"confirm_sso": "Wyloguj tylko z Bichon",
"desc": "Czy na pewno chcesz się wylogować? Aby uzyskać dostęp do konta, będziesz musiał zalogować się ponownie.",
"full_sign_out": "Wyloguj i zakończ sesję SSO",
"sso_desc": "Wylogowanie z Bichon pozostawia sesję SSO aktywną. Dla pełnego bezpieczeństwa wybierz \"Wyloguj i zakończ sesję SSO\".",
"sso_warning": "To zakończy sesję SSO i wyloguje Cię ze wszystkich połączonych aplikacji.",
"title": "Wyloguj się"
},
"system": {

View File

@@ -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",
@@ -240,6 +242,7 @@
"imapPortPlaceholder": "Ex: 993",
"imapProxy": "Usar proxy SOCKS5 para conexão IMAP.",
"incDownload": "Intervalo",
"incSync": "Intervalo de sinc.",
"lastSync": "Última Sincronização",
"leaveEmptyToKeepExisting": "Deixe vazio para manter a senha existente. Insira a nova senha apenas se estiver atualizando.",
"leaveEmptyToKeepPassword": "Deixe vazio para manter a senha atual",
@@ -271,6 +274,7 @@
"refreshToken": "Token de Atualização",
"refreshTokenCopiedToClipboard": "Token de atualização copiado para a área de transferência",
"relative": "Relativo",
"runGapFill": "Verifique novos e-mails e preencha automaticamente e-mails antigos ausentes",
"runningState": {
"account": {
"id": "ID da conta"
@@ -283,24 +287,34 @@
"no_active_download": "Nenhum download em andamento",
"no_errors_current": "Sem erros na sessão atual",
"no_errors_session": "Sem erros nesta sessão",
"no_gap_fill_folders": "Nenhuma pasta sincronizando mensagens ausentes",
"no_gap_fill_history": "Nenhum histórico de backfill",
"no_global_errors": "Sem erros globais",
"no_history": "Sem histórico"
},
"folders": "caixas de correio",
"gap_fill_active": "Tarefa de backfill em execução",
"gap_fill_downloaded_suffix": "baixados",
"gap_fill_failed_suffix": "falharam",
"latest": "RECENTE",
"loading": {
"fetching_account_state": "Obtendo estado da conta..."
},
"message": "Mensagem",
"session": {
"current_folder": "Pasta de e-mail atual",
"elapsed": "Tempo decorrido",
"last_update": "Última atualização",
"started_at": "Hora de início",
"status": "Status",
"trigger": "Gatilho"
},
"syncing": "Sincronizando",
"tabs": {
"active_session": "Sessão ativa",
"errors": "Erros",
"folders": "Caixas de correio",
"gap_fill": "Preenchimento de lacunas",
"history": "Histórico"
}
},
@@ -321,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",
@@ -341,6 +363,7 @@
"sinceRelativeDesc": "Baixar apenas e-mails do período recente (ex: últimos 3 meses). A data de início avança automaticamente com o tempo.",
"sinceRelativeValue": "Baixar e-mails dos últimos",
"startDownload": "Iniciar download",
"startDownloadConfirmDesc": "Iniciar download para as contas selecionadas?",
"state": "Estado",
"status": "Status",
"step": "Passo {{index}}",
@@ -457,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}})",
@@ -474,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.",
@@ -484,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."
@@ -644,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",
@@ -667,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",
@@ -677,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",
@@ -689,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",
@@ -778,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",
@@ -1409,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",
@@ -1446,7 +1588,11 @@
},
"sign_out": {
"confirm": "Sair",
"confirm_sso": "Sair apenas do Bichon",
"desc": "Tem certeza de que deseja sair? Você precisará entrar novamente para acessar sua conta.",
"full_sign_out": "Sair e encerrar sessão SSO",
"sso_desc": "Sair do Bichon mantém a sessão SSO ativa. Para segurança total, escolha \"Sair e encerrar sessão SSO\".",
"sso_warning": "Isso encerrará sua sessão SSO e fará o logoff de todos os apps conectados.",
"title": "Sair"
},
"system": {

View File

@@ -98,6 +98,7 @@
"allMailSkipped": "Выбраны стандартные папки. Папка 'Вся почта' пропущена во избежание дубликатов.",
"areYouSureYouWantTo": "Вы уверены, что хотите {{action}} этот аккаунт?",
"auth": "Авторизация",
"authPassword": "Пароль аутентификации",
"authType": "тип_авторизации",
"autoConfiguring": "Автоматическая настройка…",
"autoDiscover": "Автоопределение настроек сервера",
@@ -116,6 +117,7 @@
"clickSaveWhenDone": "Нажмите сохранить, когда закончите.",
"continue": "Продолжить",
"createdAt": "Создано",
"creating": "Создание аккаунта...",
"creationFailed": "Ошибка создания, попробуйте позже",
"cronAdvanced": "Расширенное выражение",
"cronDaily": "Ежедневно",
@@ -240,6 +242,7 @@
"imapPortPlaceholder": "например, 993",
"imapProxy": "Использовать SOCKS5 прокси для соединений IMAP.",
"incDownload": "Интервал",
"incSync": "Интервал синхр.",
"lastSync": "Посл. синхр.",
"leaveEmptyToKeepExisting": "Оставьте пустым, чтобы сохранить существующий пароль, или введите новый для обновления.",
"leaveEmptyToKeepPassword": "Оставьте пустым, чтобы сохранить текущий пароль",
@@ -271,6 +274,7 @@
"refreshToken": "Refresh Token",
"refreshTokenCopiedToClipboard": "Refresh token скопирован в буфер обмена",
"relative": "Относительная",
"runGapFill": "Проверять новые письма и автоматически восполнять старые недостающие сообщения",
"runningState": {
"account": {
"id": "ID аккаунта"
@@ -283,24 +287,34 @@
"no_active_download": "Нет активных загрузок",
"no_errors_current": "Нет ошибок в текущей сессии",
"no_errors_session": "Нет ошибок в этой сессии",
"no_gap_fill_folders": "Нет папок для загрузки недостающих писем",
"no_gap_fill_history": "История восполнения отсутствует",
"no_global_errors": "Нет глобальных ошибок",
"no_history": "Нет истории"
},
"folders": "почтовые ящики",
"gap_fill_active": "Выполняемый запуск восполнения",
"gap_fill_downloaded_suffix": "скачано",
"gap_fill_failed_suffix": "ошибок",
"latest": "ПОСЛЕДНЕЕ",
"loading": {
"fetching_account_state": "Загрузка состояния аккаунта..."
},
"message": "Сообщение",
"session": {
"current_folder": "Текущая почтовая папка",
"elapsed": "Прошло времени",
"last_update": "Последнее обновление",
"started_at": "Время начала",
"status": "Статус",
"trigger": "Триггер"
},
"syncing": "Синхронизация",
"tabs": {
"active_session": "Активная сессия",
"errors": "Ошибки",
"folders": "Почтовые ящики",
"gap_fill": "Восполнение пропусков",
"history": "История"
}
},
@@ -321,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": "С определенной даты",
@@ -341,6 +363,7 @@
"sinceRelativeDesc": "Скачивать письма только за последний период (напр., за 3 месяца). Дата начала автоматически сдвигается со временем.",
"sinceRelativeValue": "Скачать письма за последние",
"startDownload": "Запустить загрузку",
"startDownloadConfirmDesc": "Начать загрузку почты для выбранных аккаунтов?",
"state": "Состояние",
"status": "Статус",
"step": "Шаг {{index}}",
@@ -457,6 +480,7 @@
"downloading": "Загрузка...",
"emailMessageNotFound": "Не удалось найти исходное письмо. Возможно, оно было удалено.",
"name": "Имя файла",
"preview": "Предпросмотр вложения",
"search_input_placeholder": "Поиск вложений (используйте \" \" для фразового поиска)",
"sender": "Отправитель",
"sender_with_count": "Отправитель ({{count}})",
@@ -474,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": "Неверный пароль. Пожалуйста, попробуйте снова.",
@@ -484,6 +572,7 @@
"sessionExpired": "Сессия истекла!",
"sessionExpiredDesc": "Ваш сеанс завершен из-за неактивности. Пожалуйста, войдите снова, чтобы продолжить.",
"somethingWentWrong": "Что-то пошло не так",
"ssoLogin": "Вход через SSO",
"username": "Имя пользователя",
"welcome": "Добро пожаловать в Bichon",
"youWillNeedToLogInAgain": "Вам нужно будет снова войти в систему, чтобы получить доступ к учетной записи."
@@ -644,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": "Ввести имя папки вручную",
@@ -667,6 +759,7 @@
"modeHeaderDesc": "Чтение X-Gmail-Labels / X-Bichon-Metadata из файла. Если их нет, используется имя файла.",
"noAccountFound": "Аккаунт не найден.",
"noFileYet": "Файл еще не выбран",
"noFilesSelected": "Файлы не выбраны",
"noMailboxFound": "Почтовый ящик не найден.",
"noMailboxes": "В этом аккаунте не найдено почтовых ящиков.",
"orClick": "или нажмите для обзора",
@@ -677,8 +770,11 @@
"searchAccount": "Поиск аккаунтов...",
"searchMailbox": "Поиск почтовых ящиков...",
"selectAccount": "Выберите аккаунт",
"selectAccountAndFiles": "Сначала выберите целевой аккаунт и файлы.",
"selectAccountFirst": "Сначала выберите аккаунт.",
"selectAccountRequired": "Сначала выберите целевой аккаунт.",
"selectFileFirst": "Сначала выберите файл, чтобы определить доступные параметры.",
"selectFilesRequired": "Выберите файлы для импорта.",
"selectMailbox": "Выберите почтовый ящик...",
"source": "источник",
"startImport": "Импортировать",
@@ -689,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": "Вложения",
@@ -778,10 +914,12 @@
"accounts": "Учетные записи",
"apiDocs": "Документация API",
"attachment": "Вложения",
"auditLog": "Журнал аудита",
"auth": "Авторизация",
"dashboard": "Дашборд",
"general": "Общие",
"home": "Главная",
"license": "Лицензия",
"mailbox": "Почтовый ящик",
"oauth2": "OAuth2",
"other": "Другое",
@@ -1409,6 +1547,10 @@
}
},
"proxy": "Прокси",
"proxyTest": "Проверить прокси",
"proxyTestFailed": "Ошибка прокси-соединения.",
"proxyTestSuccess": "Прокси-соединение успешно установлено!",
"proxyTesting": "Проверка прокси...",
"proxyUpdateOrAddFailed": "{{action}} не удалось, попробуйте позже",
"reset": "Сброс",
"resetRootPassword": "Сбросить Root пароль",
@@ -1446,7 +1588,11 @@
},
"sign_out": {
"confirm": "Выйти",
"confirm_sso": "Выйти только из Bichon",
"desc": "Вы уверены, что хотите выйти? Вам нужно будет снова войти, чтобы получить доступ к аккаунту.",
"full_sign_out": "Выйти и завершить сессию SSO",
"sso_desc": "Выход из Bichon оставляет сессию SSO активной. Для полной безопасности выберите \"Выйти и завершить сессию SSO\".",
"sso_warning": "Это завершит сессию SSO и выведет вас из всех связанных приложений.",
"title": "Выйти"
},
"system": {

View File

@@ -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",
@@ -240,6 +242,7 @@
"imapPortPlaceholder": "t.ex. 993",
"imapProxy": "Använd en SOCKS5-proxy för IMAP-anslutningar.",
"incDownload": "Intervall",
"incSync": "Synkintervall",
"lastSync": "Senaste synk",
"leaveEmptyToKeepExisting": "Lämna tomt för att behålla det befintliga lösenordet, eller ange ett nytt för att uppdatera det.",
"leaveEmptyToKeepPassword": "Lämna tomt för att behålla nuvarande lösenord",
@@ -271,6 +274,7 @@
"refreshToken": "Uppdateringstoken",
"refreshTokenCopiedToClipboard": "Uppdateringstoken kopierad till urklipp",
"relative": "Relativ",
"runGapFill": "Kontrollera nya e-postmeddelanden och komplettera automatiskt äldre saknade meddelanden",
"runningState": {
"account": {
"id": "Kontots ID"
@@ -283,24 +287,34 @@
"no_active_download": "Ingen aktiv nedladdning",
"no_errors_current": "Inga fel i aktuell session",
"no_errors_session": "Inga fel i denna session",
"no_gap_fill_folders": "Inga mappar för synkning av saknade meddelanden",
"no_gap_fill_history": "Ingen kompletteringshistorik",
"no_global_errors": "Inga globala fel",
"no_history": "Ingen historik"
},
"folders": "postlådor",
"gap_fill_active": "Pågående kompletteringskörning",
"gap_fill_downloaded_suffix": "nedladdade",
"gap_fill_failed_suffix": "misslyckades",
"latest": "SENASTE",
"loading": {
"fetching_account_state": "Hämtar kontostatus..."
},
"message": "Meddelande",
"session": {
"current_folder": "Aktuell e-postmapp",
"elapsed": "Tidsåtgång",
"last_update": "Senast uppdaterad",
"started_at": "Starttid",
"status": "Status",
"trigger": "Trigger"
"trigger": "Utlösare"
},
"syncing": "Synkroniserar",
"tabs": {
"active_session": "Aktiv session",
"errors": "Fel",
"folders": "Postlådor",
"gap_fill": "Komplettera saknade meddelanden",
"history": "Historik"
}
},
@@ -321,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",
@@ -341,6 +363,7 @@
"sinceRelativeDesc": "Ladda endast ner e-postmeddelanden från den senaste perioden (t.ex. senaste 3 månaderna). Startdatumet flyttas automatiskt framåt.",
"sinceRelativeValue": "Ladda ner e-post från de senaste",
"startDownload": "Starta hämtning",
"startDownloadConfirmDesc": "Starta nedladdning för valda konton?",
"state": "Tillstånd",
"status": "Status",
"step": "Steg {{index}}",
@@ -457,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}})",
@@ -474,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.",
@@ -484,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."
@@ -644,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",
@@ -667,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",
@@ -677,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",
@@ -689,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",
@@ -778,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",
@@ -1409,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",
@@ -1446,7 +1588,11 @@
},
"sign_out": {
"confirm": "Logga ut",
"confirm_sso": "Logga endast ut från Bichon",
"desc": "Är du säker på att du vill logga ut? Du måste logga in igen för att få åtkomst till ditt konto.",
"full_sign_out": "Logga ut och avsluta SSO-session",
"sso_desc": "Utloggning från Bichon behåller SSO-sessionen aktiv. För full säkerhet, välj \"Logga ut och avsluta SSO-session\".",
"sso_warning": "Detta avslutar din SSO-session och loggar ut dig från alla anslutna appar.",
"title": "Logga ut"
},
"system": {

View File

@@ -98,6 +98,7 @@
"allMailSkipped": "標準資料夾已選擇,為避免重複已跳過「所有郵件」。",
"areYouSureYouWantTo": "你確定要{{action}}此帳戶嗎?",
"auth": "驗證",
"authPassword": "驗證密碼",
"authType": "驗證類型",
"autoConfiguring": "正在自動設定…",
"autoDiscover": "自動偵測伺服器設定",
@@ -116,6 +117,7 @@
"clickSaveWhenDone": "完成後點擊「儲存」。",
"continue": "繼續",
"createdAt": "建立時間",
"creating": "正在建立帳號...",
"creationFailed": "建立失敗,請稍後再試。",
"cronAdvanced": "高級表達式",
"cronDaily": "每天",
@@ -240,6 +242,7 @@
"imapPortPlaceholder": "例如993",
"imapProxy": "使用 SOCKS5 代理進行 IMAP 連線。",
"incDownload": "間隔",
"incSync": "同步間隔",
"lastSync": "上次同步",
"leaveEmptyToKeepExisting": "保留現有密碼請留空。若要更新,請輸入新密碼。",
"leaveEmptyToKeepPassword": "保留現有密碼請留空",
@@ -271,6 +274,7 @@
"refreshToken": "更新權杖 (Refresh Token)",
"refreshTokenCopiedToClipboard": "更新權杖已複製到剪貼簿",
"relative": "相對",
"runGapFill": "檢查並下載新郵件,同時自動補全本地缺失的歷史舊郵件",
"runningState": {
"account": {
"id": "帳戶 ID"
@@ -283,24 +287,34 @@
"no_active_download": "目前沒有下載任務",
"no_errors_current": "目前任務沒有錯誤",
"no_errors_session": "此任務沒有錯誤",
"no_gap_fill_folders": "暫無需要補充缺失郵件的郵件資料夾",
"no_gap_fill_history": "暫無查漏補缺歷史記錄",
"no_global_errors": "沒有全域錯誤",
"no_history": "沒有歷史記錄"
},
"folders": "個郵件夾",
"gap_fill_active": "執行中的查漏補缺任務",
"gap_fill_downloaded_suffix": "已下載",
"gap_fill_failed_suffix": "失敗",
"latest": "最新",
"loading": {
"fetching_account_state": "正在取得帳戶狀態..."
},
"message": "訊息",
"session": {
"current_folder": "目前郵件資料夾",
"elapsed": "耗時",
"last_update": "最近更新",
"started_at": "開始時間",
"status": "狀態",
"trigger": "觸發方式"
},
"syncing": "同步中",
"tabs": {
"active_session": "目前任務",
"errors": "錯誤",
"folders": "郵件夾",
"gap_fill": "缺失郵件查漏補缺",
"history": "歷史記錄"
}
},
@@ -321,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": "自特定日期起",
@@ -341,6 +363,7 @@
"sinceRelativeDesc": "僅下載最近一段時間(如過去 3 個月)的郵件。開始日期會隨時間推移自動向前滾動。",
"sinceRelativeValue": "下載最近一段時期的郵件",
"startDownload": "啟動下載",
"startDownloadConfirmDesc": "確定開始下載所選帳號的郵件資料?",
"state": "狀態",
"status": "狀態",
"step": "步驟 {{index}}",
@@ -457,6 +480,7 @@
"downloading": "正在下載...",
"emailMessageNotFound": "找不到原始郵件。它可能已被刪除。",
"name": "檔案名稱",
"preview": "預覽附件",
"search_input_placeholder": "搜尋附件(使用 \" \" 進行短語搜尋)",
"sender": "寄件人",
"sender_with_count": "寄件人 ({{count}})",
@@ -474,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": "密碼無效,請再試一次。",
@@ -484,6 +572,7 @@
"sessionExpired": "連線逾時!",
"sessionExpiredDesc": "由於閒置過久,您的連線已終止。請重新登入以繼續。",
"somethingWentWrong": "發生錯誤",
"ssoLogin": "SSO 單點登入",
"username": "使用者名稱",
"welcome": "歡迎使用 Bichon",
"youWillNeedToLogInAgain": "您將需要再次登入才能存取您的帳號。"
@@ -644,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": "指定自訂資料夾名稱",
@@ -667,6 +759,7 @@
"modeHeaderDesc": "讀取檔案中的 X-Gmail-Labels / X-Bichon-Metadata 標籤,未識別時預設使用檔案名稱。",
"noAccountFound": "未找到相關帳戶。",
"noFileYet": "尚未選擇任何檔案",
"noFilesSelected": "未選擇任何檔案",
"noMailboxFound": "未找到郵箱。",
"noMailboxes": "該帳戶下未找到任何郵箱。",
"orClick": "或點擊瀏覽檔案",
@@ -677,8 +770,11 @@
"searchAccount": "搜尋帳戶...",
"searchMailbox": "搜尋郵箱...",
"selectAccount": "選擇帳戶",
"selectAccountAndFiles": "請先選擇目標郵箱帳號和檔案。",
"selectAccountFirst": "請先選擇一個帳戶。",
"selectAccountRequired": "請先選擇目標郵箱帳號。",
"selectFileFirst": "請先選擇檔案以確定可用選項。",
"selectFilesRequired": "請選擇要匯入的檔案。",
"selectMailbox": "選擇郵箱...",
"source": "來源",
"startImport": "開始匯入",
@@ -689,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": "附件",
@@ -778,10 +914,12 @@
"accounts": "帳號",
"apiDocs": "API文件",
"attachment": "附件",
"auditLog": "稽核日誌",
"auth": "驗證",
"dashboard": "儀表板",
"general": "一般",
"home": "首頁",
"license": "授權",
"mailbox": "信箱",
"oauth2": "OAuth2",
"other": "其他",
@@ -1409,6 +1547,10 @@
}
},
"proxy": "代理",
"proxyTest": "測試代理伺服器",
"proxyTestFailed": "代理伺服器連線失敗。",
"proxyTestSuccess": "代理伺服器連線成功!",
"proxyTesting": "正在測試代理伺服器...",
"proxyUpdateOrAddFailed": "{{action}} 失敗,請稍後再試",
"reset": "重設",
"resetRootPassword": "重設根目錄密碼",
@@ -1446,7 +1588,11 @@
},
"sign_out": {
"confirm": "登出",
"confirm_sso": "僅登出 Bichon",
"desc": "您確定要登出嗎?您需要重新登入才能存取帳戶。",
"full_sign_out": "登出並結束 SSO 工作階段",
"sso_desc": "僅登出 Bichon 仍會保持 SSO 工作階段。如需完全安全,請選擇「登出並結束 SSO 工作階段」。",
"sso_warning": "這將結束 SSO 工作階段並從所有關聯應用程式中登出。",
"title": "登出"
},
"system": {

View File

@@ -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": "每天",
@@ -242,6 +242,7 @@
"imapPortPlaceholder": "例如:993",
"imapProxy": "为 IMAP 连接使用 SOCKS5 代理。",
"incDownload": "间隔",
"incSync": "同步间隔",
"lastSync": "最后同步",
"leaveEmptyToKeepExisting": "留空以保持现有密码,或输入新密码进行更新。",
"leaveEmptyToKeepPassword": "留空以保持当前密码",
@@ -273,6 +274,7 @@
"refreshToken": "刷新令牌",
"refreshTokenCopiedToClipboard": "刷新令牌已复制到剪贴板",
"relative": "相对",
"runGapFill": "检查并下载新邮件,同时自动补全本地缺失的历史老邮件",
"runningState": {
"account": {
"id": "账户 ID"
@@ -285,24 +287,34 @@
"no_active_download": "当前没有下载任务",
"no_errors_current": "当前任务无错误",
"no_errors_session": "该任务无错误",
"no_gap_fill_folders": "暂无需要补充缺失邮件的邮件夹",
"no_gap_fill_history": "暂无查漏补缺历史记录",
"no_global_errors": "暂无全局错误",
"no_history": "暂无历史记录"
},
"folders": "个文件夹",
"gap_fill_active": "运行中的查漏补缺任务",
"gap_fill_downloaded_suffix": "已下载",
"gap_fill_failed_suffix": "失败",
"latest": "最新",
"loading": {
"fetching_account_state": "正在获取账户状态..."
},
"message": "消息",
"session": {
"current_folder": "当前邮件夹",
"elapsed": "耗时",
"last_update": "最近更新",
"started_at": "开始时间",
"status": "状态",
"trigger": "触发方式"
},
"syncing": "同步中",
"tabs": {
"active_session": "当前任务",
"errors": "错误",
"folders": "邮件夹",
"gap_fill": "缺失邮件查漏补缺",
"history": "历史记录"
}
},
@@ -323,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": "自特定日期起",
@@ -351,6 +363,7 @@
"sinceRelativeDesc": "仅下载最近一段时间(如过去 3 个月)的邮件。开始日期会随时间推移自动向后滚动。",
"sinceRelativeValue": "下载最近一段时期的邮件",
"startDownload": "启动下载",
"startDownloadConfirmDesc": "确定开始下载所选账号的邮件数据?",
"state": "状态",
"status": "状态",
"step": "步骤 {{index}}",
@@ -467,6 +480,7 @@
"downloading": "正在下载...",
"emailMessageNotFound": "找不到原始邮件。它可能已被删除。",
"name": "文件名",
"preview": "预览附件",
"search_input_placeholder": "搜索附件(使用 \" \" 进行短语搜索)",
"sender": "发件人",
"sender_with_count": "发件人 ({{count}})",
@@ -484,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": "密码无效,请重试。",
@@ -494,6 +572,7 @@
"sessionExpired": "会话已过期!",
"sessionExpiredDesc": "由于长时间未活动,您的会话已结束。请重新登录以继续。",
"somethingWentWrong": "出错了",
"ssoLogin": "SSO 单点登录",
"username": "用户名",
"welcome": "欢迎使用 Bichon",
"youWillNeedToLogInAgain": "您需要重新登录才能访问您的账户。"
@@ -654,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": "指定自定义文件夹名称",
@@ -677,6 +759,7 @@
"modeHeaderDesc": "读取文件中的 X-Gmail-Labels / X-Bichon-Metadata 标签,未识别时默认使用文件名。",
"noAccountFound": "未找到相关账户。",
"noFileYet": "尚未选择任何文件",
"noFilesSelected": "未选择任何文件",
"noMailboxFound": "未找到邮箱。",
"noMailboxes": "该账户下未找到任何邮箱。",
"orClick": "或点击浏览文件",
@@ -687,8 +770,11 @@
"searchAccount": "搜索账户...",
"searchMailbox": "搜索邮箱...",
"selectAccount": "选择账户",
"selectAccountAndFiles": "请先选择目标邮箱账号和文件。",
"selectAccountFirst": "请先选择一个账户。",
"selectAccountRequired": "请先选择目标邮箱账号。",
"selectFileFirst": "请先选择文件以确定可用选项。",
"selectFilesRequired": "请选择要导入的文件。",
"selectMailbox": "选择邮箱...",
"source": "来源",
"startImport": "开始导入",
@@ -699,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": "附件",
@@ -788,10 +914,12 @@
"accounts": "账户",
"apiDocs": "API 文档",
"attachment": "附件",
"auditLog": "审计日志",
"auth": "认证",
"dashboard": "仪表板",
"general": "常规",
"home": "首页",
"license": "许可证",
"mailbox": "邮箱",
"oauth2": "OAuth2",
"other": "其他",
@@ -1419,6 +1547,10 @@
}
},
"proxy": "网络代理",
"proxyTest": "测试代理",
"proxyTestFailed": "代理连接失败。",
"proxyTestSuccess": "代理连接成功!",
"proxyTesting": "正在测试代理...",
"proxyUpdateOrAddFailed": "{{action}}失败,请稍后重试",
"reset": "重置",
"resetRootPassword": "重置root账户密码",
@@ -1456,7 +1588,11 @@
},
"sign_out": {
"confirm": "退出登录",
"confirm_sso": "仅退出 Bichon",
"desc": "您确定要退出登录吗?您需要重新登录才能访问账户。",
"full_sign_out": "退出登录并结束 SSO 会话",
"sso_desc": "仅退出 Bichon 仍会保持 SSO 会话。如需完全安全,请选择“退出登录并结束 SSO 会话”。",
"sso_warning": "这将结束 SSO 会话并从所有关联应用中退出。",
"title": "退出登录"
},
"system": {

View File

@@ -26,7 +26,7 @@ import {
QueryClientProvider,
} from '@tanstack/react-query'
import { RouterProvider, createRouter } from '@tanstack/react-router'
import { resetToken } from '@/stores/authStore'
import { resetToken, setToken } from '@/stores/authStore'
import { toast } from '@/hooks/use-toast'
import { ThemeProvider } from './context/theme-context'
import './index.css'
@@ -117,6 +117,16 @@ const queryClient = new QueryClient({
const basepath = (window as any).__BICHON_BASE__ || '/';
console.log('Current Basepath:', basepath);
// OIDC SSO callback: the Pro server redirects back with ?access_token=.
// Store it and strip it from the URL before the router/auth flow runs.
const ssoToken = new URLSearchParams(window.location.search).get('access_token');
if (ssoToken) {
setToken({ success: true, access_token: ssoToken });
const clean = `${window.location.pathname}${window.location.hash}`;
window.history.replaceState(null, '', clean);
}
// Create a new router instance
const router = createRouter({
routeTree,

View File

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

View 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,
})

View 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,
})