15 Commits
0.2.0 ... 0.2.2

Author SHA1 Message Date
rustmailer
44fc0e15de bump versions 2025-12-31 22:57:31 +08:00
rustmailer
ef891b20c3 fix(account): update sync range and handle all-mode reset 2025-12-31 22:57:20 +08:00
rustmailer
a6216c2ce6 feat: support restoring single message to IMAP #77 2025-12-31 22:56:06 +08:00
rustmailer
1c58b516dd update sign-out dialog 2025-12-31 02:44:43 +08:00
rustmailer
ae916574de Fix: modifying the admin user 2025-12-31 02:43:57 +08:00
rustmailer
14fb3368a3 udpate locales files 2025-12-31 02:40:58 +08:00
rustmailer
f49929dd67 Update message.rs 2025-12-30 22:41:51 +08:00
rustmailer
97143d55b8 Merge pull request #67 from mmaudet/feat/envelope-endpoint-and-api-improvements
feat(api): Add envelope endpoint and improve API documentation
2025-12-30 22:32:26 +08:00
rustmailer
e666f76d87 Merge branch 'main' into feat/envelope-endpoint-and-api-improvements 2025-12-30 22:32:11 +08:00
rustmailer
7fb6575f8d feat: use email 'Date' header for statistics and search filtering #87 2025-12-30 22:21:53 +08:00
rustmailer
fb0be8c5d1 bump version to 0.2.1 2025-12-30 15:11:57 +08:00
rustmailer
455e6b1a75 feat: support user appearance preferences with persisted theme and language #85 2025-12-30 15:09:41 +08:00
rustmailer
75cae51be9 feat(ui): Add quick page navigation to the email list pagination #85 2025-12-30 11:40:09 +08:00
rustmailer
62d956c7d6 feat: increase password max to 256, fix i18n, and force re-login #83
- Raise password maximum length from 32 to 256 characters
- fix profileSchema to accept `t` for proper internationalization
- Invalidate user's WebUI token on password change, requiring re-login
2025-12-30 11:05:07 +08:00
Michel-Marie MAUDET
70db81dc03 feat(api): Add envelope endpoint and improve API documentation
- Add GET /envelope/{account_id}/{message_id} endpoint to retrieve message envelope (metadata)
- Add get_envelope_by_id method to ENVELOPE_INDEX_MANAGER for querying single envelope
- Move message_id from query parameter to path parameter for clearer API paths:
  - /message-content/{account_id}/{message_id}
  - /download-message/{account_id}/{message_id}
  - /download-attachment/{account_id}/{message_id}
  - /envelope/{account_id}/{message_id}
- Fix API documentation descriptions to be more accurate:
  - search_messages: Now correctly describes search functionality
  - get_thread_messages: Mentions thread_id requirement
  - proxy endpoints: Fixed copy-paste errors from OAuth2 docs
- Update frontend API client to use new path-based URLs

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-14 11:12:16 +01:00
68 changed files with 2199 additions and 252 deletions

46
Cargo.lock generated
View File

@@ -424,7 +424,7 @@ dependencies = [
[[package]]
name = "bichon"
version = "0.2.0"
version = "0.2.2"
dependencies = [
"ahash",
"async-imap",
@@ -1537,9 +1537,9 @@ dependencies = [
[[package]]
name = "governor"
version = "0.10.2"
version = "0.10.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e23d5986fd4364c2fb7498523540618b4b8d92eec6c36a02e565f66748e2f79"
checksum = "9efcab3c1958580ff1f25a2a41be1668f7603d849bb63af523b208a3cc1223b8"
dependencies = [
"cfg-if",
"dashmap",
@@ -1686,9 +1686,9 @@ dependencies = [
[[package]]
name = "html2text"
version = "0.16.4"
version = "0.16.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1418e1f5886c4a3ac23a22a5590a4907935f13edb0526593dc02a769eecefdbe"
checksum = "89f3074c1e4a7c4b4f7aca411a610e0f2f27e16d571d0e584524b66c93204100"
dependencies = [
"html5ever",
"tendril",
@@ -2131,9 +2131,9 @@ dependencies = [
[[package]]
name = "itoa"
version = "1.0.15"
version = "1.0.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c"
checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2"
[[package]]
name = "jobserver"
@@ -3498,9 +3498,9 @@ dependencies = [
[[package]]
name = "rustix"
version = "1.1.2"
version = "1.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e"
checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34"
dependencies = [
"bitflags",
"errno",
@@ -3536,9 +3536,9 @@ dependencies = [
[[package]]
name = "rustls-pki-types"
version = "1.13.1"
version = "1.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "708c0f9d5f54ba0272468c1d306a52c495b31fa155e91bc25371e6df7996908c"
checksum = "21e6f2ab2928ca4291b86736a8bd920a277a399bba1589409d72154ff87c1282"
dependencies = [
"web-time",
"zeroize",
@@ -3667,15 +3667,15 @@ dependencies = [
[[package]]
name = "serde_json"
version = "1.0.145"
version = "1.0.148"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c"
checksum = "3084b546a1dd6289475996f182a22aba973866ea8e8b02c51d9f46b1336a22da"
dependencies = [
"itoa",
"memchr",
"ryu",
"serde",
"serde_core",
"zmij",
]
[[package]]
@@ -4295,9 +4295,9 @@ dependencies = [
[[package]]
name = "tempfile"
version = "3.23.0"
version = "3.24.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2d31c77bdf42a745371d260a26ca7163f1e0924b64afa0b688e61b5a9fa02f16"
checksum = "655da9c7eb6305c55742045d5a8d2037996d61d8de95806335c7c86ce0f82e9c"
dependencies = [
"fastrand 2.3.0",
"getrandom 0.3.4",
@@ -4633,9 +4633,9 @@ checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
[[package]]
name = "tracing"
version = "0.1.43"
version = "0.1.44"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2d15d90a0b5c19378952d479dc858407149d7bb45a14de0142f6c534b16fc647"
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
dependencies = [
"log",
"pin-project-lite",
@@ -4668,9 +4668,9 @@ dependencies = [
[[package]]
name = "tracing-core"
version = "0.1.35"
version = "0.1.36"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7a04e24fab5c89c6a36eb8558c9656f30d81de51dfa4d3b45f26b21d61fa0a6c"
checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
dependencies = [
"once_cell",
"valuable",
@@ -5727,6 +5727,12 @@ dependencies = [
"syn 2.0.111",
]
[[package]]
name = "zmij"
version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3280a1b827474fcd5dbef4b35a674deb52ba5c312363aef9135317df179d81b"
[[package]]
name = "zstd"
version = "0.13.3"

View File

@@ -1,6 +1,6 @@
[package]
name = "bichon"
version = "0.2.0"
version = "0.2.2"
edition = "2021"
[[bin]]
@@ -37,9 +37,9 @@ poem-openapi = { version = "5.1.16", features = [
] }
ring = { version = "0.17.14", features = ["std"] }
serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.145"
serde_json = "1.0.148"
tokio = { version = "1.48.0", features = ["full"] }
tracing = "0.1.43"
tracing = "0.1.44"
tracing-appender = "0.2.3"
tracing-subscriber = { version = "0.3.22", features = ["env-filter", "json"] }
base64 = "0.22.1"
@@ -83,11 +83,11 @@ async-imap = { version = "0.11.1", default-features = false, features = [
] }
webpki-roots = "1.0.4"
rustls = { version = "0.23.35", default-features = false, features = ["ring"] }
rustls-pki-types = "1.13.1"
rustls-pki-types = "1.13.2"
tokio-io-timeout = "1.2.1"
bb8 = "0.9.1"
semver = "1.0.27"
governor = "0.10.2"
governor = "0.10.4"
lru = "0.16.2"
mime_guess = "2.0.5"
hex = "0.4.3"
@@ -105,10 +105,10 @@ dashmap = "6.1.0"
openssl-sys = { version = "0.9.111", optional = true, features = ["vendored"] }
gethostname = "1.1.0"
tantivy = { version = "0.25.0", features = ["quickwit", "zstd-compression"] }
itoa = "1.0.15"
html2text = "0.16.4"
itoa = "1.0.17"
html2text = "0.16.5"
bytes = "1.11.0"
[dev-dependencies]
#bincode = "1.3.3"
#secret-lib = "1.0.0"
tempfile = "3.23.0"
tempfile = "3.24.0"

View File

@@ -28,7 +28,7 @@ use crate::{
users::{
permissions::Permission,
role::{RoleType, UserRole},
BichonUser,
UserModel,
},
},
raise_error, utc_now,
@@ -68,7 +68,7 @@ impl BatchAccountRoleRequest {
}
for id in &self.user_ids {
let exists = BichonUser::find(*id).await?; // Assuming an exists helper
let exists = UserModel::find(*id).await?; // Assuming an exists helper
if exists.is_none() {
return Err(raise_error!(
format!("User ID {} not found", id),
@@ -90,7 +90,7 @@ impl BatchAccountRoleRequest {
// Fetch the current user record from the database
let user = rw
.get()
.primary::<BichonUser>(uid)
.primary::<UserModel>(uid)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
.ok_or_else(|| {
raise_error!(

View File

@@ -36,7 +36,7 @@ use crate::{
database::{list_all_impl, with_transaction},
error::BichonResult,
indexer::manager::{EML_INDEX_MANAGER, ENVELOPE_INDEX_MANAGER},
users::{role::DEFAULT_ACCOUNT_MANAGER_ROLE_ID, BichonUser, DEFAULT_ADMIN_USER_ID},
users::{role::DEFAULT_ACCOUNT_MANAGER_ROLE_ID, UserModel, DEFAULT_ADMIN_USER_ID},
},
utc_now,
};
@@ -195,13 +195,6 @@ impl AccountV3 {
ErrorCode::ResourceNotFound
)
})?;
// if !account.enabled {
// return Err(raise_error!(
// format!("Account id='{account_id}' is disabled"),
// ErrorCode::AccountDisabled
// ));
// }
Ok(account)
}
@@ -221,11 +214,6 @@ impl AccountV3 {
.await
}
// /// Saves the current `AccountEntity` by persisting it to storage.
// pub async fn save(&self) -> BichonResult<()> {
// insert_impl(DB_MANAGER.meta_db(), self.to_owned()).await
// }
pub async fn create_account(
user_id: u64,
request: AccountCreateRequest,
@@ -238,7 +226,7 @@ impl AccountV3 {
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
let user = rw
.get()
.primary::<BichonUser>(user_id)
.primary::<UserModel>(user_id)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
.ok_or_else(|| {
raise_error!(
@@ -312,7 +300,7 @@ impl AccountV3 {
MAIL_CONTEXT.clean_account(account.id).await?;
}
OAuth2AccessToken::try_delete(account.id).await?;
BichonUser::cleanup_account(account.id).await?;
UserModel::cleanup_account(account.id).await?;
MailBox::clean(account.id).await?;
ENVELOPE_INDEX_MANAGER
.delete_account_envelopes(account.id)
@@ -420,6 +408,13 @@ impl AccountV3 {
new.date_since = None;
}
if let Some(clear_date_range) = request.clear_date_range {
if clear_date_range {
new.date_since = None;
new.date_before = None;
}
}
if let Some(folder_limit) = request.folder_limit {
new.folder_limit = Some(folder_limit);
}

View File

@@ -121,6 +121,7 @@ pub struct AccountUpdateRequest {
/// - Reducing server load during resyncs
pub date_since: Option<DateSince>,
pub date_before: Option<RelativeDate>,
pub clear_date_range: Option<bool>,
/// Max emails to sync for this folder.
/// If not set, sync all emails.
/// otherwise sync up to `n` most recent emails (min 10).
@@ -165,6 +166,15 @@ impl AccountUpdateRequest {
));
}
if self.clear_date_range == Some(true)
&& (self.date_since.is_some() || self.date_before.is_some())
{
return Err(raise_error!(
"clear_date_range cannot be combined with date_since or date_before".into(),
ErrorCode::InvalidParameter
));
}
if let Some(date_since) = self.date_since.as_ref() {
date_since.validate()?;
}

View File

@@ -27,7 +27,7 @@ use crate::modules::{
migration::{AccountModel, AccountType},
since::{DateSince, RelativeDate},
},
users::BichonUser,
users::UserModel,
};
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize, Object)]
@@ -57,7 +57,7 @@ pub struct AccountResp {
}
impl AccountResp {
pub fn from_model(account: AccountModel, user_map: &HashMap<u64, BichonUser>) -> AccountResp {
pub fn from_model(account: AccountModel, user_map: &HashMap<u64, UserModel>) -> AccountResp {
let user = user_map.get(&account.created_by);
AccountResp {
id: account.id,

View File

@@ -20,7 +20,7 @@ use crate::{
modules::{
error::{code::ErrorCode, BichonResult},
token::AccessTokenModel,
users::{permissions::Permission, role::UserRole, BichonUser},
users::{permissions::Permission, role::UserRole, UserModel},
utils::rate_limit::RATE_LIMITER_MANAGER,
},
raise_error,
@@ -74,7 +74,7 @@ impl<E: Endpoint> Endpoint for ApiGuardEndpoint<E> {
#[derive(Clone, Debug)]
pub struct ClientContext {
pub ip_addr: Option<IpAddr>,
pub user: BichonUser,
pub user: UserModel,
}
impl ClientContext {

View File

@@ -21,6 +21,7 @@ use crate::modules::cache::imap::MAILBOX_MODELS;
use crate::modules::error::{code::ErrorCode, BichonError};
use crate::modules::settings::cli::SETTINGS;
use crate::modules::settings::dir::DATA_DIR_MANAGER;
use crate::modules::users::UserModel;
use crate::modules::{database::META_MODELS, error::BichonResult};
use crate::raise_error;
use native_db::{Builder, Database};
@@ -73,6 +74,8 @@ impl DatabaseManager {
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
rw.migrate::<AccountModel>()
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
rw.migrate::<UserModel>()
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
rw.commit()
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;

View File

@@ -27,7 +27,7 @@ use crate::modules::settings::proxy::Proxy;
use crate::modules::settings::system::SystemSetting;
use crate::modules::token::AccessTokenModel;
use crate::modules::users::role::UserRole;
use crate::modules::users::BichonUser;
use crate::modules::users::{BichonUser, BichonUserV2};
use crate::raise_error;
use db_type::{KeyOptions, ToKeyDefinition};
use itertools::Itertools;
@@ -73,6 +73,7 @@ impl ModelsAdapter {
self.register_model::<Proxy>();
self.register_model::<UserRole>();
self.register_model::<BichonUser>();
self.register_model::<BichonUserV2>();
self.register_model::<AccessTokenModel>();
}
}

View File

@@ -79,12 +79,26 @@ impl ImapExecutor {
Ok(result)
}
pub async fn append(
&self,
mailbox_name: impl AsRef<str>,
flags: Option<&str>,
internaldate: Option<&str>,
content: impl AsRef<[u8]>,
) -> BichonResult<()> {
let mut session = self.get_connection().await?;
session
.append(mailbox_name, flags, internaldate, content)
.await
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::ImapCommandFailed))
}
pub async fn fetch_new_mail(
&self,
account: &AccountModel,
mailbox: &MailBox,
start_uid: u64,
before: Option<&str>
before: Option<&str>,
) -> BichonResult<()> {
assert!(start_uid > 0, "start_uid must be greater than 0");
@@ -93,12 +107,7 @@ impl ImapExecutor {
None => format!("UID {start_uid}:*"),
};
let uid_list = self
.uid_search(
&mailbox.encoded_name(),
&query,
)
.await?;
let uid_list = self.uid_search(&mailbox.encoded_name(), &query).await?;
let len = uid_list.len();
if len == 0 {

View File

@@ -34,8 +34,8 @@ use crate::{
indexer::{
envelope::Envelope,
fields::{
F_ACCOUNT_ID, F_FROM, F_HAS_ATTACHMENT, F_INTERNAL_DATE, F_MAILBOX_ID, F_SIZE,
F_TAGS, F_THREAD_ID, F_UID,
F_ACCOUNT_ID, F_DATE, F_FROM, F_HAS_ATTACHMENT, F_MAILBOX_ID, F_SIZE, F_TAGS,
F_THREAD_ID, F_UID,
},
schema::SchemaTools,
},
@@ -335,13 +335,13 @@ impl EnvelopeIndexManager {
}
let start_bound = if let Some(from) = filter.since {
Bound::Included(Term::from_field_i64(f.f_internal_date, from))
Bound::Included(Term::from_field_i64(f.f_date, from))
} else {
Bound::Unbounded
};
let end_bound = if let Some(to) = filter.before {
Bound::Included(Term::from_field_i64(f.f_internal_date, to))
Bound::Included(Term::from_field_i64(f.f_date, to))
} else {
Bound::Unbounded
};
@@ -658,7 +658,7 @@ impl EnvelopeIndexManager {
&query,
&TopDocs::with_limit(page_size as usize)
.and_offset(offset as usize)
.order_by_fast_field(F_INTERNAL_DATE, order),
.order_by_fast_field(F_DATE, order),
)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
let mut result = Vec::new();
@@ -721,7 +721,7 @@ impl EnvelopeIndexManager {
query.as_ref(),
&TopDocs::with_limit(page_size as usize)
.and_offset(offset as usize)
.order_by_fast_field(F_INTERNAL_DATE, order),
.order_by_fast_field(F_DATE, order),
)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
let mut result = Vec::new();
@@ -786,7 +786,7 @@ impl EnvelopeIndexManager {
query.as_ref(),
&TopDocs::with_limit(page_size as usize)
.and_offset(offset as usize)
.order_by_fast_field(F_INTERNAL_DATE, order),
.order_by_fast_field(F_DATE, order),
)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
let mut result = Vec::new();
@@ -808,6 +808,48 @@ impl EnvelopeIndexManager {
})
}
pub async fn get_envelope_by_id(
&self,
account_id: u64,
message_id: u64,
) -> BichonResult<Option<Envelope>> {
let searcher = self.create_searcher()?;
let f = SchemaTools::envelope_fields();
let query = BooleanQuery::new(vec![
(
Occur::Must,
Box::new(TermQuery::new(
Term::from_field_u64(f.f_account_id, account_id),
IndexRecordOption::Basic,
)),
),
(
Occur::Must,
Box::new(TermQuery::new(
Term::from_field_u64(f.f_id, message_id),
IndexRecordOption::Basic,
)),
),
]);
let docs: Vec<(f32, DocAddress)> = searcher
.search(&query, &TopDocs::with_limit(1))
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
if let Some((_, doc_address)) = docs.first() {
let doc: TantivyDocument = searcher
.doc_async(*doc_address)
.await
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
let envelope = Envelope::from_tantivy_doc(&doc).await?;
Ok(Some(envelope))
} else {
Ok(None)
}
}
pub async fn top_10_largest_emails(
&self,
accounts: &Option<HashSet<u64>>,
@@ -1006,7 +1048,7 @@ impl EnvelopeIndexManager {
},
"recent_30d_histogram": {
"histogram": {
"field": F_INTERNAL_DATE,
"field": F_DATE,
"interval": 86400000,
"hard_bounds": {
"min": week_ago_ms,

View File

@@ -16,7 +16,6 @@
// 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::modules::account::migration::{AccountModel, AccountType};
use crate::modules::cache::imap::mailbox::{Attribute, AttributeEnum, MailBox};
use crate::modules::context::executors::MAIL_CONTEXT;
@@ -64,8 +63,15 @@ pub async fn convert_names_to_mailboxes(
for name in names.into_iter() {
// Convert the name into a MailBox structure
let mailbox_name = name.name().to_string();
let mut mailbox: MailBox = name.into();
tracing::debug!(
raw = &mailbox_name,
decoded = &mailbox.name,
"mailbox name comparison"
);
if contains_no_select(&mailbox.attributes) {
continue;
}

View File

@@ -0,0 +1,104 @@
use crate::{
encode_mailbox_name,
modules::{
account::migration::{AccountModel, AccountType},
context::executors::MAIL_CONTEXT,
error::{code::ErrorCode, BichonResult},
indexer::manager::{EML_INDEX_MANAGER, ENVELOPE_INDEX_MANAGER},
},
raise_error,
};
use poem_openapi::Object;
use serde::{Deserialize, Serialize};
const MAX_RESTORE_COUNT: usize = 100;
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize, Object)]
pub struct RestoreMessagesRequest {
/// Message IDs to restore (max 100)
pub message_ids: Vec<u64>,
}
pub async fn restore_emails(account_id: u64, message_ids: Vec<u64>) -> BichonResult<()> {
if message_ids.len() > MAX_RESTORE_COUNT {
return Err(raise_error!(
format!(
"Too many messages to restore: {} (max {})",
message_ids.len(),
MAX_RESTORE_COUNT
),
ErrorCode::InvalidParameter
));
}
let account = AccountModel::check_account_exists(account_id).await?;
if !matches!(account.account_type, AccountType::IMAP) {
return Err(raise_error!(
"Account type is not IMAP".into(),
ErrorCode::Incompatible
));
}
let executor = MAIL_CONTEXT.imap(account.id).await?;
let mut failed = Vec::new();
for message_id in message_ids {
let result: BichonResult<()> = async {
let envelope = ENVELOPE_INDEX_MANAGER
.get_envelope_by_id(account_id, message_id)
.await?
.ok_or_else(|| {
raise_error!(
format!(
"Envelope not found: account_id={} message_id={}",
account_id, message_id
),
ErrorCode::ResourceNotFound
)
})?;
let eml = EML_INDEX_MANAGER
.get(account_id, message_id)
.await?
.ok_or_else(|| {
raise_error!(
format!(
"Email record not found: account_id={} id={}",
account_id, message_id
),
ErrorCode::ResourceNotFound
)
})?;
if let Some(mailbox_name) = envelope.mailbox_name {
executor
.append(encode_mailbox_name!(&mailbox_name), None, None, &eml)
.await?;
}
Ok(())
}
.await;
if let Err(err) = result {
failed.push(message_id);
tracing::warn!(
account_id = account_id,
message_id = message_id,
error = ?err,
"Failed to restore email"
);
}
}
if !failed.is_empty() {
tracing::info!(
account_id = account_id,
failed_count = failed.len(),
failed_message_ids = ?failed,
"Restore emails finished with partial failures"
);
}
Ok(())
}

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/>.
pub mod append;
pub mod content;
pub mod delete;
pub mod list;

View File

@@ -32,7 +32,7 @@ use crate::modules::rest::api::ApiTags;
use crate::modules::rest::response::DataPage;
use crate::modules::rest::ApiResult;
use crate::modules::users::permissions::Permission;
use crate::modules::users::BichonUser;
use crate::modules::users::UserModel;
use crate::raise_error;
use poem_openapi::param::{Path, Query};
use poem_openapi::payload::Json;
@@ -131,7 +131,7 @@ impl AccountApi {
let is_admin = context.user.is_admin().await;
let sort_desc = desc.0.unwrap_or(true);
let user_map: HashMap<u64, BichonUser> = BichonUser::list_all()
let user_map: HashMap<u64, UserModel> = UserModel::list_all()
.await?
.into_iter()
.map(|u| (u.id, u))

View File

@@ -21,6 +21,8 @@ use crate::modules::common::auth::ClientContext;
use crate::modules::indexer::envelope::Envelope;
use crate::modules::indexer::manager::EML_INDEX_MANAGER;
use crate::modules::indexer::manager::ENVELOPE_INDEX_MANAGER;
use crate::modules::message::append::restore_emails;
use crate::modules::message::append::RestoreMessagesRequest;
use crate::modules::message::content::{retrieve_email_content, FullMessageContent};
use crate::modules::message::delete::delete_messages_impl;
use crate::modules::message::list::{get_thread_messages, list_messages_impl};
@@ -66,7 +68,7 @@ impl MessageApi {
Ok(delete_messages_impl(request).await?)
}
/// Lists messages in a specified mailbox for the given account.
/// Lists messages in a mailbox. Requires `mailbox_id`, `page`, and `page_size` query parameters.
#[oai(
path = "/list-messages/:account_id",
method = "get",
@@ -74,7 +76,9 @@ impl MessageApi {
)]
async fn list_messages(
&self,
/// The ID of the account.
account_id: Path<u64>,
/// The ID of the mailbox to list messages from.
mailbox_id: Query<u64>,
page: Query<u64>,
page_size: Query<u64>,
@@ -90,7 +94,8 @@ impl MessageApi {
))
}
/// Lists messages in a specified mailbox for the given account.
/// Searches messages across all mailboxes using various filter criteria.
/// The search filters are provided in the request body.
#[oai(
path = "/search-messages",
method = "post",
@@ -112,7 +117,7 @@ impl MessageApi {
Ok(Json(search_messages_impl(authorized_ids, payload.0).await?))
}
/// Get thread's envelopes in a specified mailbox for the given account.
/// Retrieves all messages belonging to a specific thread. Requires `thread_id`, `page`, and `page_size` query parameters.
#[oai(
path = "/get-thread-messages/:account_id",
method = "get",
@@ -140,9 +145,9 @@ impl MessageApi {
))
}
/// Fetches the content of a specific email for the given account.
/// Fetches the content of a specific email.
#[oai(
path = "/message-content/:account_id",
path = "/message-content/:account_id/:message_id",
method = "get",
operation_id = "fetch_message_content"
)]
@@ -151,19 +156,54 @@ impl MessageApi {
/// The ID of the account.
account_id: Path<u64>,
/// The ID of the message to fetch.
message_id: Query<u64>,
message_id: Path<u64>,
context: ClientContext,
) -> ApiResult<Json<FullMessageContent>> {
let account_id = account_id.0;
context
.require_permission(Some(account_id), Permission::DATA_READ)
.await?;
Ok(Json(retrieve_email_content(account_id, message_id.0).await?))
Ok(Json(
retrieve_email_content(account_id, message_id.0).await?,
))
}
/// Fetches the full content of a specific email for the given account.
/// Retrieves the envelope (metadata) of a specific message.
#[oai(
path = "/download-message/:account_id",
path = "/envelope/:account_id/:message_id",
method = "get",
operation_id = "get_envelope"
)]
async fn get_envelope(
&self,
/// The ID of the account.
account_id: Path<u64>,
/// The ID of the message.
message_id: Path<u64>,
context: ClientContext,
) -> ApiResult<Json<Envelope>> {
let account_id = account_id.0;
context
.require_permission(Some(account_id), Permission::DATA_READ)
.await?;
let envelope = ENVELOPE_INDEX_MANAGER
.get_envelope_by_id(account_id, message_id.0)
.await?
.ok_or_else(|| {
raise_error!(
format!(
"Envelope not found: account_id={} message_id={}",
account_id, message_id.0
),
ErrorCode::ResourceNotFound
)
})?;
Ok(Json(envelope))
}
/// Downloads the raw EML file of a specific email.
#[oai(
path = "/download-message/:account_id/:message_id",
method = "get",
operation_id = "download_message"
)]
@@ -172,7 +212,7 @@ impl MessageApi {
/// The ID of the account.
account_id: Path<u64>,
/// The ID of the message to download.
message_id: Query<u64>,
message_id: Path<u64>,
context: ClientContext,
) -> ApiResult<Attachment<Body>> {
let account_id = account_id.0;
@@ -189,9 +229,28 @@ impl MessageApi {
Ok(attachment)
}
/// Downloads a specific attachment by filename.
#[oai(
path = "/download-attachment/:account_id",
path = "/restore-messages/:account_id",
method = "post",
operation_id = "restore_messages"
)]
async fn restore_messages(
&self,
account_id: Path<u64>,
/// Message IDs to restore.
payload: Json<RestoreMessagesRequest>,
context: ClientContext,
) -> ApiResult<()> {
let account_id = account_id.0;
context
.require_permission(Some(account_id), Permission::DATA_EXPORT_BATCH)
.await?;
Ok(restore_emails(account_id, payload.0.message_ids).await?)
}
/// Downloads a specific attachment from an email. Requires `name` query parameter.
#[oai(
path = "/download-attachment/:account_id/:message_id",
method = "get",
operation_id = "download_attachment"
)]
@@ -200,7 +259,7 @@ impl MessageApi {
/// The ID of the account.
account_id: Path<u64>,
/// The ID of the message containing the attachment.
message_id: Query<u64>,
message_id: Path<u64>,
/// The filename of the attachment to download.
name: Query<String>,
context: ClientContext,
@@ -210,10 +269,9 @@ impl MessageApi {
context
.require_permission(Some(account_id), Permission::DATA_READ)
.await?;
let message_id = message_id.0;
let name = name.0.trim();
let reader = EML_INDEX_MANAGER
.get_attachment(account_id, message_id, name)
.get_attachment(account_id, message_id.0, name)
.await?;
let body = Body::from_async_read(reader);
let attachment = Attachment::new(body)

View File

@@ -86,7 +86,7 @@ impl SystemApi {
#[oai(path = "/proxy/:id", method = "delete", operation_id = "remove_proxy")]
async fn remove_proxy(
&self,
/// The name of the OAuth2 configuration to retrieve
/// The ID of the proxy configuration to delete.
id: Path<u64>,
context: ClientContext,
) -> ApiResult<()> {
@@ -96,11 +96,11 @@ impl SystemApi {
Ok(Proxy::delete(id.0).await?)
}
/// Retrieve a specific proxy configuration by ID
/// Retrieve a specific proxy configuration by ID. Requires root permission.
#[oai(path = "/proxy/:id", method = "get", operation_id = "get_proxy")]
async fn get_proxy(
&self,
/// The name of the OAuth2 configuration to retrieve
/// The ID of the proxy configuration to retrieve.
id: Path<u64>,
context: ClientContext,
) -> ApiResult<Json<Proxy>> {

View File

@@ -29,7 +29,7 @@ use crate::modules::users::payload::{
use crate::modules::users::permissions::Permission;
use crate::modules::users::role::UserRole;
use crate::modules::users::view::UserView;
use crate::modules::users::BichonUser;
use crate::modules::users::UserModel;
use poem::web::Path;
use poem_openapi::payload::Json;
use poem_openapi::OpenApi;
@@ -100,10 +100,10 @@ impl UsersApi {
.await?;
let roles = UserRole::list_all().await?;
let role_lookup: BTreeMap<u64, UserRole> = roles.into_iter().map(|r| (r.id, r)).collect();
let users = BichonUser::list_all().await?;
let users = UserModel::list_all().await?;
let users = users
.into_iter()
.map(|u| u.to_current_user(&role_lookup))
.map(|u| u.to_view(&role_lookup))
.collect();
Ok(Json(users))
}
@@ -140,7 +140,7 @@ impl UsersApi {
context
.require_permission(None, Permission::USER_MANAGE)
.await?;
Ok(BichonUser::remove(id).await?)
Ok(UserModel::remove(id).await?)
}
#[oai(path = "/users", method = "post", operation_id = "create_user")]
@@ -152,10 +152,10 @@ impl UsersApi {
context
.require_permission(None, Permission::USER_MANAGE)
.await?;
let user = BichonUser::create(payload.0).await?;
let user = UserModel::create(payload.0).await?;
let roles = UserRole::list_all().await?;
let role_lookup: BTreeMap<u64, UserRole> = roles.into_iter().map(|r| (r.id, r)).collect();
Ok(Json(user.to_current_user(&role_lookup)))
Ok(Json(user.to_view(&role_lookup)))
}
#[oai(path = "/users/:id", method = "post", operation_id = "update_user")]
@@ -180,7 +180,7 @@ impl UsersApi {
update_data.account_access_map = None;
update_data.acl = None;
}
Ok(BichonUser::update(target_id, update_data).await?)
Ok(UserModel::update(target_id, update_data).await?)
}
#[oai(
@@ -191,7 +191,7 @@ impl UsersApi {
async fn get_current_user(&self, context: ClientContext) -> ApiResult<Json<UserView>> {
let roles = UserRole::list_all().await?;
let role_lookup: BTreeMap<u64, UserRole> = roles.into_iter().map(|r| (r.id, r)).collect();
Ok(Json(context.user.to_current_user(&role_lookup)))
Ok(Json(context.user.to_view(&role_lookup)))
}
#[oai(

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::modules::users::BichonUser;
use crate::modules::users::UserModel;
use poem::{handler, web::Json, IntoResponse, Response};
use serde::Deserialize;
use tracing::error;
@@ -34,7 +34,7 @@ pub struct LoginPayload {
#[handler]
pub async fn login(payload: Json<LoginPayload>) -> Response {
let payload = payload.0;
match BichonUser::authenticate_user(payload.username, payload.password).await {
match UserModel::authenticate_user(payload.username, payload.password).await {
Ok(result) => match serde_json::to_string(&result) {
Ok(json_string) => Response::builder()
.status(http::StatusCode::OK)

View File

@@ -26,7 +26,7 @@ use crate::modules::database::{
use crate::modules::database::{insert_impl, list_all_impl, update_impl};
use crate::modules::settings::cli::SETTINGS;
use crate::modules::token::view::AccessTokenResp;
use crate::modules::users::BichonUser;
use crate::modules::users::UserModel;
use crate::raise_error;
use crate::{
generate_token, modules::error::BichonResult,
@@ -180,7 +180,7 @@ impl AccessTokenModel {
.collect())
}
pub async fn resolve_user_from_token(token: &str) -> BichonResult<BichonUser> {
pub async fn resolve_user_from_token(token: &str) -> BichonResult<UserModel> {
let token = token.to_string();
let token_option = async_find_impl::<AccessTokenModel>(DB_MANAGER.meta_db(), token).await?;
let token = match token_option {
@@ -237,7 +237,7 @@ impl AccessTokenModel {
.await?;
}
let user = BichonUser::find(token.user_id)
let user = UserModel::find(token.user_id)
.await?
.ok_or_else(|| raise_error!("The user associated with this access token does not exist or may have been deleted.".into(), ErrorCode::ResourceNotFound))?;
Ok(user)
@@ -287,11 +287,11 @@ impl AccessTokenModel {
}
pub async fn list_all_api_tokens() -> BichonResult<Vec<AccessTokenResp>> {
let users = BichonUser::list_all().await?;
let users = UserModel::list_all().await?;
let mut all = list_all_impl::<AccessTokenModel>(DB_MANAGER.meta_db()).await?;
all.retain(|t| t.token_type == TokenType::Api);
let user_map: HashMap<u64, BichonUser> = users.into_iter().map(|u| (u.id, u)).collect();
let user_map: HashMap<u64, UserModel> = users.into_iter().map(|u| (u.id, u)).collect();
let resp = all
.into_iter()

View File

@@ -19,7 +19,7 @@
use crate::modules::{
context::Initialize,
error::BichonResult,
users::{role::UserRole, BichonUser},
users::{role::UserRole, UserModel},
};
pub struct UserManager;
@@ -27,6 +27,6 @@ pub struct UserManager;
impl Initialize for UserManager {
async fn initialize() -> BichonResult<()> {
UserRole::ensure_default_roles_exists().await?;
BichonUser::ensure_default_admin_exists().await
UserModel::ensure_default_admin_exists().await
}
}

View File

@@ -51,11 +51,15 @@ pub mod permissions;
pub mod role;
pub mod view;
pub type UserModel = BichonUserV2;
#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize, Object)]
pub struct LoginResult {
pub success: bool,
pub error_message: Option<String>,
pub access_token: Option<String>,
pub theme: Option<String>,
pub language: Option<String>,
}
pub const DEFAULT_ADMIN_USER_ID: u64 = 100000000000000;
@@ -92,9 +96,44 @@ pub struct BichonUser {
pub acl: Option<AccessControl>,
}
impl BichonUser {
pub async fn list_all() -> BichonResult<Vec<BichonUser>> {
Ok(list_all_impl::<BichonUser>(DB_MANAGER.meta_db()).await?)
#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize, Object)]
#[native_model(id = 10, version = 2, from = BichonUser)]
#[native_db]
pub struct BichonUserV2 {
#[primary_key]
pub id: u64,
#[secondary_key(unique)]
pub username: String,
#[secondary_key(unique)]
pub email: String,
pub password: Option<String>,
/// Scoped Access: Defines per-account permissions.
/// Example:
/// { account_id: 1, role_id: role_manager_id } -> Manager on Account 1
/// { account_id: 2, role_id: role_viewer_id } -> Viewer on Account 2
pub account_access_map: BTreeMap<u64, u64>,
pub description: Option<String>,
/// System Roles: Permissions that apply to the whole system
/// (e.g., system settings, creating new users).
pub global_roles: Vec<u64>,
pub avatar: Option<String>,
pub created_at: i64,
pub updated_at: i64,
/// Optional access control settings
pub acl: Option<AccessControl>,
pub theme: Option<String>,
pub language: Option<String>,
}
impl BichonUserV2 {
pub async fn list_all() -> BichonResult<Vec<UserModel>> {
Ok(list_all_impl::<UserModel>(DB_MANAGER.meta_db()).await?)
}
async fn get_all_permissions(&self) -> HashSet<String> {
@@ -111,7 +150,7 @@ impl BichonUser {
all_perms
}
pub fn to_current_user(self, role_lookup: &BTreeMap<u64, UserRole>) -> UserView {
pub fn to_view(self, role_lookup: &BTreeMap<u64, UserRole>) -> UserView {
let global_roles_names = self
.global_roles
.iter()
@@ -173,6 +212,8 @@ impl BichonUser {
acl: self.acl,
account_permissions,
global_permissions,
theme: self.theme,
language: self.language,
}
}
@@ -187,12 +228,12 @@ impl BichonUser {
// 1. Try to get the existing admin user
let admin = rw
.get()
.primary::<BichonUser>(DEFAULT_ADMIN_USER_ID)
.primary::<UserModel>(DEFAULT_ADMIN_USER_ID)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
if admin.is_none() {
// 2. Insert the BichonUser with the updated schema
rw.insert(BichonUser {
rw.insert(UserModel {
id: DEFAULT_ADMIN_USER_ID,
username: "admin".into(),
email: "placeholder@example.com".into(),
@@ -209,6 +250,8 @@ impl BichonUser {
updated_at: now,
description: Some("System default administrator".into()),
acl: None,
theme: None,
language: None,
})
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
@@ -239,9 +282,9 @@ impl BichonUser {
username: String,
password: String,
) -> BichonResult<LoginResult> {
let user_option = secondary_find_impl::<BichonUser>(
let user_option = secondary_find_impl::<UserModel>(
DB_MANAGER.meta_db(),
BichonUserKey::username,
BichonUserV2Key::username,
username.clone(),
)
.await?;
@@ -249,9 +292,9 @@ impl BichonUser {
let user = match user_option {
Some(u) => u,
None => {
match secondary_find_impl::<BichonUser>(
match secondary_find_impl::<UserModel>(
DB_MANAGER.meta_db(),
BichonUserKey::email,
BichonUserV2Key::email,
username,
)
.await?
@@ -262,6 +305,8 @@ impl BichonUser {
success: false,
error_message: Some("User or email not found.".to_string()),
access_token: None,
theme: None,
language: None,
});
}
}
@@ -277,6 +322,8 @@ impl BichonUser {
success: true,
error_message: None,
access_token: Some(new_token),
theme: user.theme,
language: user.language,
})
} else {
warn!(
@@ -287,6 +334,8 @@ impl BichonUser {
success: false,
error_message: Some("Incorrect password.".to_string()),
access_token: None,
theme: None,
language: None,
})
}
}
@@ -304,20 +353,22 @@ impl BichonUser {
)
),
access_token: None,
theme: None,
language: None,
})
}
}
}
pub async fn find(user_id: u64) -> BichonResult<Option<BichonUser>> {
pub async fn find(user_id: u64) -> BichonResult<Option<UserModel>> {
async_find_impl(DB_MANAGER.meta_db(), user_id).await
}
pub async fn check_username_conflict(username: &str) -> BichonResult<()> {
// Check username duplicate
if secondary_find_impl::<BichonUser>(
if secondary_find_impl::<UserModel>(
DB_MANAGER.meta_db(),
BichonUserKey::username,
BichonUserV2Key::username,
username.to_string(),
)
.await?
@@ -334,9 +385,9 @@ impl BichonUser {
pub async fn check_email_conflict(email: &str) -> BichonResult<()> {
// Check email duplicate
if secondary_find_impl::<BichonUser>(
if secondary_find_impl::<UserModel>(
DB_MANAGER.meta_db(),
BichonUserKey::email,
BichonUserV2Key::email,
email.to_string(),
)
.await?
@@ -351,7 +402,7 @@ impl BichonUser {
Ok(())
}
pub async fn create(request: UserCreateRequest) -> BichonResult<BichonUser> {
pub async fn create(request: UserCreateRequest) -> BichonResult<UserModel> {
request.validate().await?;
Self::check_username_conflict(&request.username).await?;
Self::check_email_conflict(&request.email).await?;
@@ -359,7 +410,7 @@ impl BichonUser {
let password_hash = Some(encrypt!(&request.password)?);
let now = utc_now!();
let user = BichonUser {
let user = UserModel {
id: id!(96),
username: request.username,
email: request.email,
@@ -371,6 +422,8 @@ impl BichonUser {
created_at: now,
updated_at: now,
account_access_map: request.account_access_map,
theme: request.theme,
language: request.language,
};
let user_clone = user.clone();
@@ -444,18 +497,22 @@ impl BichonUser {
pub async fn update(id: u64, request: UserUpdateRequest) -> BichonResult<()> {
let _ = &request.validate().await?;
let password_changed = request.password.is_some();
//
let is_default_admin = id == DEFAULT_ADMIN_USER_ID;
let is_valid_admin_roles = matches!(
request.global_roles.as_deref(),
Some([role]) if *role == DEFAULT_ADMIN_ROLE_ID
);
if DEFAULT_ADMIN_USER_ID == id && request.global_roles.is_some() {
return Err(raise_error!(
format!("The role assignments for default admin (id={}) are immutable to ensure system accessibility.", id),
ErrorCode::Forbidden
));
if is_default_admin && !is_valid_admin_roles {
return Err(raise_error!(format!("The role assignments for default admin (id={}) are immutable to ensure system accessibility.", id), ErrorCode::Forbidden));
}
if let Some(username) = &request.username {
let user_option = secondary_find_impl::<BichonUser>(
let user_option = secondary_find_impl::<UserModel>(
DB_MANAGER.meta_db(),
BichonUserKey::username,
BichonUserV2Key::username,
username.to_string(),
)
.await?;
@@ -471,9 +528,9 @@ impl BichonUser {
}
if let Some(email) = &request.email {
let user_option = secondary_find_impl::<BichonUser>(
let user_option = secondary_find_impl::<UserModel>(
DB_MANAGER.meta_db(),
BichonUserKey::email,
BichonUserV2Key::email,
email.to_string(),
)
.await?;
@@ -492,7 +549,7 @@ impl BichonUser {
DB_MANAGER.meta_db(),
move |rw| {
rw.get()
.primary::<BichonUser>(id)
.primary::<UserModel>(id)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
.ok_or_else(|| {
raise_error!(
@@ -531,18 +588,32 @@ impl BichonUser {
if let Some(avatar_base64) = request.avatar_base64 {
updated.avatar = Some(avatar_base64);
}
if let Some(theme) = request.theme {
updated.theme = Some(theme);
}
if let Some(language) = request.language {
updated.language = Some(language);
}
updated.updated_at = utc_now!();
Ok(updated)
},
)
.await?;
if password_changed {
AccessTokenModel::reset_webui_token(id).await?;
}
Ok(())
}
async fn list_authorized_users(account_id: u64) -> BichonResult<Vec<BichonUser>> {
async fn list_authorized_users(account_id: u64) -> BichonResult<Vec<UserModel>> {
let all = Self::list_all().await?;
let result: Vec<BichonUser> = all
let result: Vec<UserModel> = all
.into_iter()
.filter(|e| e.account_access_map.contains_key(&account_id))
.collect();
@@ -560,7 +631,7 @@ impl BichonUser {
for user in users {
let current = rw
.get()
.primary::<BichonUser>(user.id)
.primary::<UserModel>(user.id)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
.ok_or_else(|| {
raise_error!(
@@ -584,3 +655,41 @@ impl BichonUser {
Ok(())
}
}
impl From<BichonUserV2> for BichonUser {
fn from(value: BichonUserV2) -> Self {
BichonUser {
id: value.id,
username: value.username,
email: value.email,
password: value.password,
account_access_map: value.account_access_map,
description: value.description,
global_roles: value.global_roles,
avatar: value.avatar,
created_at: value.created_at,
updated_at: value.updated_at,
acl: value.acl,
}
}
}
impl From<BichonUser> for BichonUserV2 {
fn from(value: BichonUser) -> Self {
BichonUserV2 {
id: value.id,
username: value.username,
email: value.email,
password: value.password,
account_access_map: value.account_access_map,
description: value.description,
global_roles: value.global_roles,
avatar: value.avatar,
created_at: value.created_at,
updated_at: value.updated_at,
acl: value.acl,
theme: None,
language: None,
}
}
}

View File

@@ -31,7 +31,44 @@ use crate::{
};
use poem_openapi::Object;
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, BTreeSet, HashMap};
use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
fn allowed_themes() -> HashSet<&'static str> {
["light", "dark"].into_iter().collect()
}
fn allowed_languages() -> HashSet<&'static str> {
[
"ar", "da", "de", "en", "es", "fi", "fr", "it", "jp", "ko", "nl", "no", "pl", "pt", "ru",
"sv", "zh", "zh-tw",
]
.into_iter()
.collect()
}
fn validate_option_in_set(
value: &Option<String>,
allowed: &std::collections::HashSet<&'static str>,
field_name: &str,
) -> BichonResult<()> {
if let Some(v) = value {
if !allowed.contains(v.as_str()) {
return Err(raise_error!(
format!("invalid {} value: '{}'", field_name, v),
ErrorCode::InvalidParameter
));
}
}
Ok(())
}
fn validate_theme(theme: &Option<String>) -> BichonResult<()> {
validate_option_in_set(theme, &allowed_themes(), "theme")
}
fn validate_language(language: &Option<String>) -> BichonResult<()> {
validate_option_in_set(language, &allowed_languages(), "language")
}
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize, Object)]
pub struct RoleCreateRequest {
@@ -176,6 +213,8 @@ pub struct UserCreateRequest {
pub acl: Option<AccessControl>,
pub avatar_base64: Option<String>,
pub description: Option<String>,
pub theme: Option<String>,
pub language: Option<String>,
}
impl UserCreateRequest {
@@ -204,9 +243,9 @@ impl UserCreateRequest {
ErrorCode::InvalidParameter
));
}
if password_len > 32 {
if password_len > 256 {
return Err(raise_error!(
"Password cannot exceed 32 characters.".into(),
"Password cannot exceed 256 characters.".into(),
ErrorCode::InvalidParameter
));
}
@@ -219,6 +258,9 @@ impl UserCreateRequest {
));
}
validate_theme(&self.theme)?;
validate_language(&self.language)?;
let all_roles = UserRole::list_all().await?;
let role_type_map: HashMap<u64, RoleType> =
all_roles.into_iter().map(|r| (r.id, r.role_type)).collect();
@@ -301,6 +343,8 @@ pub struct UserUpdateRequest {
pub account_access_map: Option<BTreeMap<u64, u64>>,
pub acl: Option<AccessControl>,
pub description: Option<String>,
pub theme: Option<String>,
pub language: Option<String>,
}
impl UserUpdateRequest {
@@ -317,14 +361,17 @@ impl UserUpdateRequest {
if let Some(password) = &self.password {
let len = password.len();
if len < 8 || len > 32 {
if len < 8 || len > 256 {
return Err(raise_error!(
"Password must be 8-32 characters.".into(),
"Password must be 8-256 characters.".into(),
ErrorCode::InvalidParameter
));
}
}
validate_theme(&self.theme)?;
validate_language(&self.language)?;
let all_roles = UserRole::list_all().await?;
let role_type_map: HashMap<u64, RoleType> =
all_roles.into_iter().map(|r| (r.id, r.role_type)).collect();

View File

@@ -49,4 +49,6 @@ pub struct UserView {
pub updated_at: i64,
/// Optional access control settings
pub acl: Option<AccessControl>,
pub theme: Option<String>,
pub language: Option<String>,
}

View File

@@ -48,7 +48,7 @@ export const get_thread_messages = async (accountId: number, thread_id: number,
}
export const download_attachment = async (accountId: number, id: number, attachmentFileName: string) => {
const response = await axiosInstance.get(`/api/v1/download-attachment/${accountId}?message_id=${id}&name=${attachmentFileName}`, { responseType: 'blob' });
const response = await axiosInstance.get(`/api/v1/download-attachment/${accountId}/${id}?name=${attachmentFileName}`, { responseType: 'blob' });
const blob = new Blob([response.data]);
saveAs(blob, attachmentFileName);
};
@@ -83,7 +83,7 @@ export const getContent = (messageContent: MessageContentResponse): string | nul
};
export const load_message = async (accountId: number, id: number) => {
const response = await axiosInstance.get<MessageContentResponse>(`/api/v1/message-content/${accountId}?message_id=${id}`);
const response = await axiosInstance.get<MessageContentResponse>(`/api/v1/message-content/${accountId}/${id}`);
return response.data;
};
@@ -93,7 +93,16 @@ export const delete_messages = async (payload: Record<string, number[]>) => {
};
export const download_message = async (accountId: number, id: number) => {
const response = await axiosInstance.get(`/api/v1/download-message/${accountId}?message_id=${id}`, { responseType: 'blob' });
const response = await axiosInstance.get(`/api/v1/download-message/${accountId}/${id}`, { responseType: 'blob' });
const blob = new Blob([response.data]);
saveAs(blob, `${id}.eml`);
};
export const restore_message = async (accountId: number, messageIds: number[]) => {
const response = await axiosInstance.post(`/api/v1/restore-messages/${accountId}`, {
message_ids: messageIds,
});
return response.data;
};

View File

@@ -14,33 +14,33 @@ export interface UserRole {
}
export function getPermissions(t: (key: string) => string) {
return [
// 1. Global Management
{ label: t('permission.system.access'), value: 'system:access' },
{ label: t('permission.system.root'), value: 'system:root' },
{ label: t('permission.user.manage'), value: 'user:manage' },
{ label: t('permission.user.view'), value: 'user:view' },
{ label: t('permission.token.manage'), value: 'token:manage' },
{ label: t('permission.account.create'), value: 'account:create' },
return [
// 1. Global Management
{ label: t('permission.system.access'), value: 'system:access' },
{ label: t('permission.system.root'), value: 'system:root' },
{ label: t('permission.user.manage'), value: 'user:manage' },
{ label: t('permission.user.view'), value: 'user:view' },
{ label: t('permission.token.manage'), value: 'token:manage' },
{ label: t('permission.account.create'), value: 'account:create' },
// 2. Global "ALL" Scoped (Admin)
{ label: t('permission.account.manage_all'), value: 'account:manage:all' },
{ label: t('permission.data.read_all'), value: 'data:read:all' },
{ label: t('permission.data.manage_all'), value: 'data:manage:all' },
{ label: t('permission.data.raw_download_all'), value: 'data:raw:download:all' },
{ label: t('permission.data.delete_all'), value: 'data:delete:all' },
{ label: t('permission.data.export_batch_all'), value: 'data:export:batch:all' },
// 2. Global "ALL" Scoped (Admin)
{ label: t('permission.account.manage_all'), value: 'account:manage:all' },
{ label: t('permission.data.read_all'), value: 'data:read:all' },
{ label: t('permission.data.manage_all'), value: 'data:manage:all' },
{ label: t('permission.data.raw_download_all'), value: 'data:raw:download:all' },
{ label: t('permission.data.delete_all'), value: 'data:delete:all' },
{ label: t('permission.data.export_batch_all'), value: 'data:export:batch:all' },
// 3. Scoped / Limited
{ label: t('permission.account.manage'), value: 'account:manage' },
{ label: t('permission.account.read_details'), value: 'account:read_details' },
{ label: t('permission.data.read'), value: 'data:read' },
{ label: t('permission.data.manage'), value: 'data:manage' },
{ label: t('permission.data.raw_download'), value: 'data:raw:download' },
{ label: t('permission.data.delete'), value: 'data:delete' },
{ label: t('permission.data.export_batch'), value: 'data:export:batch' },
{ label: t('permission.data.import_batch'), value: 'data:import:batch' },
]
// 3. Scoped / Limited
{ label: t('permission.account.manage'), value: 'account:manage' },
{ label: t('permission.account.read_details'), value: 'account:read_details' },
{ label: t('permission.data.read'), value: 'data:read' },
{ label: t('permission.data.manage'), value: 'data:manage' },
{ label: t('permission.data.raw_download'), value: 'data:raw:download' },
{ label: t('permission.data.delete'), value: 'data:delete' },
{ label: t('permission.data.export_batch'), value: 'data:export:batch' },
{ label: t('permission.data.import_batch'), value: 'data:import:batch' },
]
}
export interface RateLimit {
@@ -85,10 +85,16 @@ export interface User {
created_at: number;
updated_at: number;
}
type Theme = 'dark' | 'light'
export interface LoginResult {
success: boolean;
error_message?: string | null;
access_token?: string | null;
theme?: Theme,
language?: string,
}

View File

@@ -29,6 +29,7 @@ import {
} from '@/components/ui/alert-dialog'
import { Button } from '@/components/ui/button'
import { useTranslation } from 'react-i18next'
import { Loader2 } from 'lucide-react'
interface ConfirmDialogProps {
open: boolean
@@ -79,6 +80,7 @@ export function ConfirmDialog(props: ConfirmDialogProps) {
onClick={handleConfirm}
disabled={disabled || isLoading}
>
{isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{confirmText ?? t('dialogs.continue')}
</Button>
</AlertDialogFooter>

View File

@@ -20,6 +20,8 @@
import {
ChevronLeftIcon,
ChevronRightIcon,
DoubleArrowLeftIcon,
DoubleArrowRightIcon,
} from '@radix-ui/react-icons'
import { Button } from '@/components/ui/button'
import {
@@ -30,6 +32,7 @@ import {
SelectValue,
} from '@/components/ui/select'
import { useTranslation } from 'react-i18next'
import { showNumbers } from '@/lib/utils'
interface PaginationProps {
totalItems: number
@@ -66,6 +69,9 @@ export function EnvelopeListPagination({
setPageIndex(newPageIndex)
}
const currentPage = pageIndex + 1;
const pageNumbers = showNumbers(currentPage, pageCount)
return (
<div className='flex items-center justify-between space-x-2 overflow-auto px-2'>
<div className='hidden flex-1 text-sm text-muted-foreground sm:block'>
@@ -94,6 +100,14 @@ export function EnvelopeListPagination({
{t("table.page")} {pageIndex + 1} {t("table.of")} {pageCount}
</div>
<div className='flex items-center space-x-2'>
<Button
variant='outline'
className='size-8 p-0 @max-md/content:hidden'
onClick={() => setPageIndex(0)}
disabled={pageIndex === 0}
>
<DoubleArrowLeftIcon className='h-4 w-4' />
</Button>
<Button
variant='outline'
className='h-8 w-8 p-0'
@@ -103,6 +117,22 @@ export function EnvelopeListPagination({
<span className='sr-only'>{t("table.prevPage")}</span>
<ChevronLeftIcon className='h-4 w-4' />
</Button>
{pageNumbers.map((pageNumber, index) => (
<div key={`${pageNumber}-${index}`} className='flex items-center'>
{pageNumber === '...' ? (
<span className='px-1 text-sm text-muted-foreground'>...</span>
) : (
<Button
variant={currentPage === pageNumber ? 'default' : 'outline'}
className='h-8 min-w-8 px-2'
onClick={() => setPageIndex((pageNumber as number) - 1)}
>
{pageNumber}
</Button>
)}
</div>
))}
<Button
variant='outline'
className='h-8 w-8 p-0'
@@ -112,6 +142,14 @@ export function EnvelopeListPagination({
<span className='sr-only'>{t("table.nextPage")}</span>
<ChevronRightIcon className='h-4 w-4' />
</Button>
<Button
variant='outline'
className='size-8 p-0 @max-md/content:hidden'
onClick={() => setPageIndex(pageCount - 1)}
disabled={!hasNextPage()}
>
<DoubleArrowRightIcon className='h-4 w-4' />
</Button>
</div>
</div>
</div>

View File

@@ -1,6 +1,7 @@
import { useNavigate, useLocation } from '@tanstack/react-router'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { resetToken } from '@/stores/authStore'
import { useTranslation } from 'react-i18next'
interface SignOutDialogProps {
open: boolean
@@ -10,7 +11,7 @@ interface SignOutDialogProps {
export function SignOutDialog({ open, onOpenChange }: SignOutDialogProps) {
const navigate = useNavigate()
const location = useLocation()
const { t } = useTranslation()
const handleSignOut = () => {
resetToken()
const currentPath = location.href
@@ -25,12 +26,15 @@ export function SignOutDialog({ open, onOpenChange }: SignOutDialogProps) {
<ConfirmDialog
open={open}
onOpenChange={onOpenChange}
title='Sign out'
desc='Are you sure you want to sign out? You will need to sign in again to access your account.'
confirmText='Sign out'
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')}
destructive
handleConfirm={handleSignOut}
className='sm:max-w-sm'
className="sm:max-w-sm"
/>
)
}

View File

@@ -290,7 +290,11 @@ export function AccountActionDialog({ currentRow, open, onOpenChange }: Props) {
sync_batch_size: data.sync_batch_size,
};
if (isEdit) {
updateMutation.mutate(commonData);
const isAllMode = !data.date_since && !data.date_before;
updateMutation.mutate({
...commonData,
...(isAllMode ? { clear_date_range: true } : {})
});
} else {
createMutation.mutate({ ...commonData, account_type: "IMAP" });
}

View File

@@ -43,6 +43,7 @@ import { useTranslation } from 'react-i18next'
import i18n from '@/i18n'
import { Loader2, LogIn } from 'lucide-react'
import { login } from '@/api/users/api'
import { useTheme } from '@/context/theme-context'
type UserAuthFormProps = HTMLAttributes<HTMLDivElement>
@@ -59,6 +60,7 @@ const getFormSchema = (t: (key: string, options?: Record<string, any>) => string
export function UserAuthForm({ className, ...props }: UserAuthFormProps) {
const [isLoading, setIsLoading] = useState(false)
const { setTheme } = useTheme();
const navigate = useNavigate()
const { t } = useTranslation()
@@ -86,6 +88,15 @@ export function UserAuthForm({ className, ...props }: UserAuthFormProps) {
onSuccess: (result) => {
if (result.success) {
setToken(result);
if (result.theme) {
setTheme(result.theme);
}
if (result.language) {
i18n.changeLanguage(result.language);
}
navigate({ to: redirect });
} else {
toast({

View File

@@ -19,7 +19,7 @@
import { cn, dateFnsLocaleMap, formatBytes } from "@/lib/utils"
import { formatDistanceToNow } from "date-fns"
import { MailIcon, Paperclip, Trash2 } from "lucide-react"
import { MailIcon, MoreVertical, Paperclip, TagIcon, Trash2 } from "lucide-react"
import { Skeleton } from "@/components/ui/skeleton"
import { EmailEnvelope } from "@/api"
import { useMailboxContext } from "../context"
@@ -28,6 +28,8 @@ import { MailBulkActions } from "./bulk-actions"
import { Badge } from "@/components/ui/badge"
import { useTranslation } from 'react-i18next'
import { enUS } from "date-fns/locale"
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
import { Button } from "@/components/ui/button"
interface MailListProps {
items: EmailEnvelope[]
@@ -142,7 +144,6 @@ export function MailList({
/>
<MailIcon className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
<div className="flex-1 min-w-0 grid grid-cols-1 sm:grid-cols-12 gap-1 sm:gap-0">
{/* LEFT AREA: From + Subject + Tags */}
<div className="col-span-1 sm:col-span-8 flex flex-col min-w-0 gap-1">
<div className="flex items-center gap-1 min-w-0">
<p className="text-sm font-medium truncate">{item.from}</p>
@@ -154,7 +155,6 @@ export function MailList({
{item.subject}
</h3>
{/* TAGS BELOW SUBJECT */}
<div className="flex flex-wrap gap-1 mt-0.25">
{item.tags?.map((tag, i) => (
<Badge
@@ -167,7 +167,6 @@ export function MailList({
</div>
</div>
{/* RIGHT AREA actions & meta */}
<div className="col-span-1 sm:col-span-4 flex items-center justify-end gap-1 text-xs text-muted-foreground">
{hasAttachments && (
<div className="flex items-center gap-1">
@@ -182,15 +181,44 @@ export function MailList({
{item.date && formatDistanceToNow(new Date(item.date), { addSuffix: true, locale })}
</span>
<button
onClick={(e) => {
e.stopPropagation()
handleDelete(item)
}}
className="p-1 rounded hover:bg-destructive/10 hover:text-destructive transition-all"
>
<Trash2 className="h-3 w-3" />
</button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 p-0 hover:bg-muted rounded-md"
onClick={(e) => e.stopPropagation()}
>
<MoreVertical className="h-3 w-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-44">
<DropdownMenuItem
onClick={(e) => e.stopPropagation()}
onSelect={(e) => {
e.stopPropagation();
setSelected(new Set([item.id]));
setOpen("restore");
}}
>
<TagIcon className="ml-2 h-3.5 w-3.5" />
{t('restore_message.restore_to_imap', 'Restore Mail')}
</DropdownMenuItem>
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onClick={(e) => e.stopPropagation()}
onSelect={(e) => {
e.stopPropagation();
handleDelete(item);
}}
>
<Trash2 className="ml-2 h-3.5 w-3.5" />
{t('common.delete')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</div>

View File

@@ -50,6 +50,7 @@ import { animated, useSpring } from "@react-spring/web"
import { TransitionProps } from "@mui/material/transitions"
import Collapse from "@mui/material/Collapse"
import { FolderIcon } from "lucide-react"
import { RestoreMessageDialog } from "./restore-message-dialog"
interface MailProps {
@@ -104,7 +105,7 @@ function CustomLabel({
alignItems: 'center',
}}
>
<FolderIcon className="mr-2"/>
<FolderIcon className="mr-2" />
<span className="font-medium text-sm text-inherit">
{children}
</span>
@@ -354,8 +355,8 @@ export function Mail({
<MailList
isLoading={isMessagesLoading}
items={(envelopes?.items ?? []).sort((a, b) => {
const dateA = a.internal_date;
const dateB = b.internal_date;
const dateA = a.date;
const dateB = b.date;
return dateB - dateA;
})}
/>
@@ -402,6 +403,13 @@ export function Mail({
open={open === 'move-to-trash'}
onOpenChange={() => setOpen('move-to-trash')}
/>
<RestoreMessageDialog
key='envelope-restore'
open={open === 'restore'}
onOpenChange={() => setOpen('restore')}
/>
</MailboxProvider >
)
}

View File

@@ -0,0 +1,106 @@
//
// Copyright (c) 2025 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 { restore_message } from '@/api/mailbox/envelope/api'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { toast } from '@/hooks/use-toast'
import { useMutation } from '@tanstack/react-query'
import { AxiosError } from 'axios'
import { useTranslation } from 'react-i18next'
import { useMailboxContext } from '../context'
import { ToastAction } from '@/components/ui/toast'
interface RestoreMessageDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
}
export function RestoreMessageDialog({
open,
onOpenChange
}: RestoreMessageDialogProps) {
const { t } = useTranslation()
const { selectedAccountId, selected, setSelected } = useMailboxContext();
const restoreMutation = useMutation({
mutationFn: (messageIds: number[]) =>
restore_message(selectedAccountId!, messageIds),
onSuccess: handleRestoreSuccess,
onError: handleRestoreError,
});
function handleRestoreSuccess() {
toast({
title: t('restore_message.success', 'Messages restored'),
description: t(
'restore_message.successDesc',
'The selected messages have been restored to the IMAP server.'
),
action: (
<ToastAction altText={t('common.close')}>
{t('common.close')}
</ToastAction>
),
});
setSelected(new Set());
onOpenChange(false);
}
function handleRestoreError(error: AxiosError) {
const errorMessage =
(error.response?.data as { message?: string })?.message ||
error.message ||
t('restore_message.failed', 'Failed to restore messages');
toast({
variant: 'destructive',
title: t(
'restore_message.failedTitle',
'Restore failed'
),
description: errorMessage,
action: (
<ToastAction altText={t('common.tryAgain')}>
{t('common.tryAgain')}
</ToastAction>
),
});
console.error(error);
}
return (
<ConfirmDialog
open={open}
onOpenChange={onOpenChange}
title={t('restore_message.title', 'Restore messages')}
desc={t(
'restore_message.desc',
'This action will append the selected messages from Bichon to their corresponding mailboxes on the IMAP server.'
)}
confirmText={t('restore_message.confirm', 'Restore')}
handleConfirm={() => restoreMutation.mutate(Array.from(selected))}
className="sm:max-w-sm"
isLoading={restoreMutation.isPending}
disabled={restoreMutation.isPending}
/>
)
}

View File

@@ -116,11 +116,11 @@ export function MailThreadDialog({ open, onOpenChange }: MailThreadDialogProps)
)}
{allMessages
.sort((a, b) => a.internal_date - b.internal_date)
.sort((a, b) => a.date - b.date)
.map((msg) => {
const isExpanded = expandedIds.has(msg.id);
const preview = msg.text?.slice(0, 120) + (msg.text?.length > 120 ? '...' : '');
const date = new Date(msg.internal_date);
const date = new Date(msg.date);
const formattedDate = isNaN(date.getTime())
? t('mailbox.thread.invalidDate')
: format(date, 'yyyy-MM-dd HH:mm:ss');

View File

@@ -21,7 +21,7 @@ import React from 'react'
import { MailboxData } from '@/api/mailbox/api'
import { EmailEnvelope } from '@/api'
export type MailboxDialogType = 'mailbox' | 'display' | 'move-to-trash' | 'filters'
export type MailboxDialogType = 'mailbox' | 'display' | 'move-to-trash' | 'filters' | 'restore'
interface MailboxContextType {
open: MailboxDialogType | null

View File

@@ -25,18 +25,17 @@ import { useState, useEffect } from 'react';
import { useAvailableTags } from '@/hooks/use-available-tags';
import { useUpdateTags } from '@/hooks/use-update-tags';
import { toast } from '@/hooks/use-toast';
import { EmailEnvelope } from '@/api';
import { validateTag } from '@/lib/utils';
import { useTranslation } from 'react-i18next';
import { useQueryClient } from '@tanstack/react-query';
import { useSearchContext } from './context';
interface Props {
open: boolean
onOpenChange: (open: boolean) => void
currentEnvelope: EmailEnvelope | undefined
}
export function EditTagsDialog({ open, onOpenChange, currentEnvelope }: Props) {
export function EditTagsDialog({ open, onOpenChange }: Props) {
const { tags: availableTags } = useAvailableTags();
const queryClient = useQueryClient();
const { mutate, isPending } = useUpdateTags();
@@ -45,6 +44,8 @@ export function EditTagsDialog({ open, onOpenChange, currentEnvelope }: Props) {
const [commandOpen, setCommandOpen] = useState(false);
const { t } = useTranslation();
const { currentEnvelope } = useSearchContext()
useEffect(() => {
if (open && currentEnvelope) {
setSelectedTags(currentEnvelope.tags || []);

View File

@@ -20,7 +20,7 @@
import React from 'react'
import { EmailEnvelope } from '@/api'
export type SearchDialogType = 'mailbox' | 'display' | 'delete' | 'filters' | 'tags' | 'edit-tags' | 'search-form'
export type SearchDialogType = 'mailbox' | 'display' | 'delete' | 'filters' | 'tags' | 'edit-tags' | 'search-form' | 'restore'
interface SearchContextType {
open: SearchDialogType | null

View File

@@ -38,6 +38,7 @@ import { EnvelopeTags } from './tag-facet';
import { EditTagsDialog } from './add-tag-dialog';
import { useTranslation } from 'react-i18next';
import Logo from '@/assets/logo.svg'
import { RestoreMessageDialog } from './restore-message-dialog';
export default function Search() {
const { t } = useTranslation()
@@ -187,7 +188,7 @@ export default function Search() {
<EditTagsDialog
key='edit-tags-dialog'
open={open === 'edit-tags'}
onOpenChange={() => setOpen('edit-tags')} currentEnvelope={selectedEnvelope}
onOpenChange={() => setOpen('edit-tags')}
/>
<SearchFormDialog
@@ -196,6 +197,12 @@ export default function Search() {
open={open === 'search-form'}
onOpenChange={() => setOpen('search-form')}
/>
<RestoreMessageDialog
key='restore-mail-dialog'
open={open === 'restore'}
onOpenChange={() => setOpen('restore')}
/>
</SearchProvider>
</Main>
</>

View File

@@ -243,7 +243,17 @@ export function MailList({
<TagIcon className="ml-2 h-3.5 w-3.5" />
{t('search.editTag')}
</DropdownMenuItem>
<DropdownMenuItem
onClick={(e) => e.stopPropagation()}
onSelect={(e) => {
e.stopPropagation();
setCurrentEnvelope(item);
setOpen("restore");
}}
>
<TagIcon className="ml-2 h-3.5 w-3.5" />
{t('restore_message.restore_to_imap', 'Restore Mail')}
</DropdownMenuItem>
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onClick={(e) => e.stopPropagation()}

View File

@@ -0,0 +1,105 @@
//
// Copyright (c) 2025 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 { restore_message } from '@/api/mailbox/envelope/api'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { toast } from '@/hooks/use-toast'
import { useMutation } from '@tanstack/react-query'
import { AxiosError } from 'axios'
import { useTranslation } from 'react-i18next'
import { ToastAction } from '@/components/ui/toast'
import { useSearchContext } from './context'
interface RestoreMessageDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
}
export function RestoreMessageDialog({
open,
onOpenChange
}: RestoreMessageDialogProps) {
const { t } = useTranslation()
const { currentEnvelope } = useSearchContext()
const restoreMutation = useMutation({
mutationFn: () =>
restore_message(currentEnvelope!.account_id, [currentEnvelope!.id]),
onSuccess: handleRestoreSuccess,
onError: handleRestoreError,
});
function handleRestoreSuccess() {
toast({
title: t('restore_message.success', 'Messages restored'),
description: t(
'restore_message.successDesc',
'The selected messages have been restored to the IMAP server.'
),
action: (
<ToastAction altText={t('common.close')}>
{t('common.close')}
</ToastAction>
),
});
onOpenChange(false);
}
function handleRestoreError(error: AxiosError) {
const errorMessage =
(error.response?.data as { message?: string })?.message ||
error.message ||
t('restore_message.failed', 'Failed to restore messages');
toast({
variant: 'destructive',
title: t(
'restore_message.failedTitle',
'Restore failed'
),
description: errorMessage,
action: (
<ToastAction altText={t('common.tryAgain')}>
{t('common.tryAgain')}
</ToastAction>
),
});
console.error(error);
}
return (
<ConfirmDialog
open={open}
onOpenChange={onOpenChange}
title={t('restore_message.title', 'Restore messages')}
desc={t(
'restore_message.desc',
'This action will append the selected messages from Bichon to their corresponding mailboxes on the IMAP server.'
)}
confirmText={t('restore_message.confirm', 'Restore')}
handleConfirm={() => restoreMutation.mutate()}
className="sm:max-w-sm"
isLoading={restoreMutation.isPending}
disabled={restoreMutation.isPending}
/>
)
}

View File

@@ -113,13 +113,13 @@ export function MailThreadDialog({ open, onOpenChange }: MailThreadDialogProps)
)}
{allMessages
.sort((a, b) => a.internal_date - b.internal_date)
.sort((a, b) => a.date - b.date)
.map((msg) => {
const isExpanded = expandedIds.has(msg.id);
const preview =
msg.text?.slice(0, 120) +
(msg.text?.length > 120 ? '...' : '');
const date = new Date(msg.internal_date);
const date = new Date(msg.date);
const formattedDate = isNaN(date.getTime())
? t('search.thread.invalidDate')
: format(date, 'yyyy-MM-dd HH:mm:ss');

View File

@@ -0,0 +1,239 @@
import { z } from 'zod'
import { useForm } from 'react-hook-form'
import { CaretSortIcon, CheckIcon } from '@radix-ui/react-icons'
import { zodResolver } from '@hookform/resolvers/zod'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form'
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
import { useTheme } from '@/context/theme-context'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command'
import { useTranslation } from 'react-i18next'
import { useCurrentUser } from '@/hooks/use-current-user'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { update_user } from '@/api/users/api'
import { toast } from '@/hooks/use-toast'
import { AxiosError } from 'axios'
const languages = [
{ value: 'ar', label: 'العربية' },
{ value: 'da', label: 'Dansk' },
{ value: 'de', label: 'Deutsch' },
{ value: 'en', label: 'English' },
{ value: 'es', label: 'Español' },
{ value: 'fi', label: 'Suomi' },
{ value: 'fr', label: 'Français' },
{ value: 'it', label: 'Italiano' },
{ value: 'jp', label: '日本語' },
{ value: 'ko', label: '한국어' },
{ value: 'nl', label: 'Nederlands' },
{ value: 'no', label: 'Norsk' },
{ value: 'pl', label: 'Polski' },
{ value: 'pt', label: 'Português' },
{ value: 'ru', label: 'Русский' },
{ value: 'sv', label: 'Svenska' },
{ value: 'zh', label: '中文' },
{ value: 'zh-tw', label: '繁體中文' },
]
const appearanceSchema = (t: (key: string) => string) => z.object({
theme: z.enum(['light', 'dark'], {
required_error: t('settings.appearance.validation.theme.required'),
}),
language: z.string({
required_error: t('settings.appearance.validation.language.required'),
})
})
type AppearanceFormValues = z.infer<ReturnType<typeof appearanceSchema>>
export function AppearanceForm() {
const { data: user } = useCurrentUser();
const queryClient = useQueryClient();
const { t, i18n } = useTranslation();
const { theme, setTheme } = useTheme();
const form = useForm<AppearanceFormValues>({
resolver: zodResolver(appearanceSchema(t)),
mode: 'onChange',
defaultValues: {
theme: (theme as 'light' | 'dark') || 'light',
language: i18n.language || 'en',
},
})
const mutation = useMutation({
mutationFn: async (values: AppearanceFormValues) => {
return update_user(user!.id, values)
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['current-user'] })
toast({ title: t('settings.profile.toast.updated') })
},
onError: (err: AxiosError) => {
toast({
variant: 'destructive',
title: t('settings.profile.toast.update_failed'),
description: (err.response?.data as any)?.message || err.message,
})
},
});
function onSubmit(data: AppearanceFormValues) {
i18n.changeLanguage(data.language);
setTheme(data.theme);
mutation.mutate(data);
}
return (
<div className="w-full max-w-6xl ml-0 px-4">
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-8 w-full max-w-screen-xl mx-auto px-4 md:px-6"
>
<FormField
control={form.control}
name='language'
render={({ field }) => (
<FormItem className='flex flex-col'>
<FormLabel>{t('settings.appearance.field.language')}</FormLabel>
<Popover>
<PopoverTrigger asChild>
<FormControl>
<Button
variant='outline'
role='combobox'
className={cn(
'w-[400px] justify-between',
!field.value && 'text-muted-foreground'
)}
>
{field.value
? languages.find((l) => l.value === field.value)?.label
: t('settings.appearance.placeholder.select_language')}
<CaretSortIcon className='ms-2 h-4 w-4 shrink-0 opacity-50' />
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent className='w-[400px] p-0' align="start">
<Command>
<CommandInput placeholder={t('settings.appearance.command.search')} />
<CommandEmpty>{t('settings.appearance.command.no_results')}</CommandEmpty>
<CommandList>
<CommandGroup>
{languages.map((language) => (
<CommandItem
value={language.label}
key={language.value}
onSelect={() => {
form.setValue('language', language.value)
}}
>
<CheckIcon
className={cn(
'mr-2 h-4 w-4',
language.value === field.value ? 'opacity-100' : 'opacity-0'
)}
/>
{language.label}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
<FormDescription>
{t('settings.appearance.description.language')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='theme'
render={({ field }) => (
<FormItem className="space-y-1">
<FormLabel>{t('settings.appearance.field.theme')}</FormLabel>
<FormDescription>
{t('settings.appearance.description.theme')}
</FormDescription>
<FormMessage />
<RadioGroup
onValueChange={field.onChange}
defaultValue={field.value}
className='grid max-w-md grid-cols-2 gap-8 pt-2'
>
<FormItem>
<FormLabel className='[&:has([data-state=checked])>div]:border-primary cursor-pointer'>
<FormControl>
<RadioGroupItem value='light' className='sr-only' />
</FormControl>
<div className='items-center rounded-md border-2 border-muted p-1 hover:border-accent'>
<div className='space-y-2 rounded-sm bg-[#ecedef] p-2'>
<div className='space-y-2 rounded-md bg-white p-2 shadow-sm'>
<div className='h-2 w-[80px] rounded-lg bg-[#ecedef]' />
<div className='h-2 w-[100px] rounded-lg bg-[#ecedef]' />
</div>
<div className='flex items-center space-x-2 rounded-md bg-white p-2 shadow-sm'>
<div className='h-4 w-4 rounded-full bg-[#ecedef]' />
<div className='h-2 w-[100px] rounded-lg bg-[#ecedef]' />
</div>
</div>
</div>
<span className='block w-full p-2 text-center font-normal'>
{t('settings.appearance.theme.light')}
</span>
</FormLabel>
</FormItem>
<FormItem>
<FormLabel className='[&:has([data-state=checked])>div]:border-primary cursor-pointer'>
<FormControl>
<RadioGroupItem value='dark' className='sr-only' />
</FormControl>
<div className='items-center rounded-md border-2 border-muted bg-popover p-1 hover:bg-accent hover:text-accent-foreground'>
<div className='space-y-2 rounded-sm bg-slate-950 p-2'>
<div className='space-y-2 rounded-md bg-slate-800 p-2 shadow-sm'>
<div className='h-2 w-[80px] rounded-lg bg-slate-400' />
<div className='h-2 w-[100px] rounded-lg bg-slate-400' />
</div>
<div className='flex items-center space-x-2 rounded-md bg-slate-800 p-2 shadow-sm'>
<div className='h-4 w-4 rounded-full bg-slate-400' />
<div className='h-2 w-[100px] rounded-lg bg-slate-400' />
</div>
</div>
</div>
<span className='block w-full p-2 text-center font-normal'>
{t('settings.appearance.theme.dark')}
</span>
</FormLabel>
</FormItem>
</RadioGroup>
</FormItem>
)}
/>
<div className="flex justify-start pt-4">
<Button type='submit'>
{t('settings.appearance.button.update')}
</Button>
</div>
</form>
</Form>
</div>
)
}

View File

@@ -0,0 +1,7 @@
import { AppearanceForm } from './appearance-form'
export function SettingsAppearance() {
return (
<AppearanceForm />
)
}

View File

@@ -20,7 +20,7 @@
import { Outlet } from '@tanstack/react-router'
import { Main } from '@/components/layout/main'
import SidebarNav from './components/sidebar-nav'
import { KeyRound, SettingsIcon, UserCog, Waypoints } from 'lucide-react'
import { KeyRound, Palette, SettingsIcon, UserCog, Waypoints } from 'lucide-react'
import { FixedHeader } from '@/components/layout/fixed-header'
import { useCurrentUser } from '@/hooks/use-current-user'
import { useTranslation } from 'react-i18next'
@@ -36,6 +36,11 @@ export default function Settings() {
href: '/settings/profile',
icon: <UserCog size={18} />,
},
{
title: t('settings.appearance.title'),
href: '/settings/appearance',
icon: <Palette size={18} />,
},
{
title: t('settings.sidebar.apiTokens'),
href: '/settings/api-tokens',

View File

@@ -47,40 +47,40 @@ import AvatarUpload from './avatar-upload'
import useMinimalAccountList from '@/hooks/use-minimal-account-list'
import { PermissionsDialog } from './permissions-dialog'
const profileSchema = z.object({
const profileSchema = (t: (key: string) => string) => z.object({
username: z
.string({
required_error: 'settings.profile.validation.username.required',
required_error: t('settings.profile.validation.username.required'),
})
.min(5, {
message: 'settings.profile.validation.username.min',
message: t('settings.profile.validation.username.min'),
})
.max(32, {
message: 'settings.profile.validation.username.max',
message: t('settings.profile.validation.username.max'),
}),
email: z
.string({
required_error: 'settings.profile.validation.email.required',
required_error: t('settings.profile.validation.email.required'),
})
.email({
message: 'settings.profile.validation.email.invalid',
message: t('settings.profile.validation.email.invalid'),
}),
password: z
.string()
.min(8, {
message: 'settings.profile.validation.password.min',
message: t('settings.profile.validation.password.min'),
})
.max(32, {
message: 'settings.profile.validation.password.max',
.max(256, {
message: t('settings.profile.validation.password.max'),
})
.or(z.literal(''))
.optional()
.transform((v) => (v ? v : undefined)),
})
export type ProfileFormValues = z.infer<typeof profileSchema>
export type ProfileFormValues = z.infer<ReturnType<typeof profileSchema>>
function fileToBase64(file: File): Promise<string> {
return new Promise((resolve, reject) => {
@@ -110,7 +110,7 @@ export function UserProfileForm({ user }: UserProfileFormProps) {
useState<number | undefined>(undefined)
const form = useForm<ProfileFormValues>({
resolver: zodResolver(profileSchema),
resolver: zodResolver(profileSchema(t)),
mode: 'onChange',
defaultValues: {
username: user.username,

View File

@@ -111,14 +111,14 @@ const createUserSchema = (t: any) => z.object({
password: z.string()
.min(1, t('users.actions.schema.password_required'))
.min(8, t('users.actions.schema.password_min'))
.max(32, t('users.actions.schema.password_max')),
.max(256, t('users.actions.schema.password_max')),
});
const updateUserSchema = (t: any) => z.object({
...baseUserSchema(t),
password: z.string()
.min(8, t('users.actions.schema.password_min'))
.max(32, t('users.actions.schema.password_max'))
.max(256, t('users.actions.schema.password_max'))
.or(z.literal(''))
.optional()
.transform(v => v || undefined),

View File

@@ -171,4 +171,36 @@ export const dateFnsLocaleMap: Record<string, Locale> = {
'sv-se': sv,
no: nb,
'no-no': nb,
};
};
export function showNumbers(current: number, total: number) {
const max = 5
const result = []
if (total <= max) {
for (let i = 1; i <= total; i++) {
result.push(i)
}
} else {
result.push(1)
if (current <= 3) {
for (let i = 2; i <= 4; i++) {
result.push(i)
}
result.push('...', total)
} else if (current >= total - 2) {
result.push('...')
for (let i = total - 3; i <= total; i++) {
result.push(i)
}
} else {
result.push('...')
for (let i = current - 1; i <= current + 1; i++) {
result.push(i)
}
result.push('...', total)
}
}
return result
}

View File

@@ -718,6 +718,39 @@
"interval": "الفاصل الزمني"
},
"settings": {
"appearance": {
"title": "المظهر",
"field": {
"language": "لغة الواجهة",
"theme": "سمة الواجهة"
},
"description": {
"language": "اختر اللغة المستخدمة في واجهة الويب.",
"theme": "اختر بين الوضع الفاتح أو الداكن."
},
"placeholder": {
"select_language": "اختر اللغة"
},
"command": {
"search": "بحث عن لغة...",
"no_results": "لم يتم العثور على لغة."
},
"theme": {
"light": "فاتح",
"dark": "داكن"
},
"button": {
"update": "تحديث التفضيلات"
},
"validation": {
"language": {
"required": "Please select a language."
},
"theme": {
"required": "Please select a theme."
}
}
},
"title": "الإعدادات",
"general": "عام",
"proxy": "وكيل",
@@ -829,7 +862,7 @@
},
"password": {
"min": "يجب أن تكون كلمة المرور 8 أحرف على الأقل",
"max": "يجب ألا تزيد كلمة المرور عن 32 حرفًا"
"max": "يجب ألا تزيد كلمة المرور عن 256 حرفًا"
}
}
}
@@ -1238,7 +1271,7 @@
"ip_invalid": "تم اكتشاف عنوان IP غير صالح",
"password_required": "كلمة المرور مطلوبة",
"password_min": "8 أحرف على الأقل",
"password_max": "32 حرفاً كحد أقصى"
"password_max": "256 حرفاً كحد أقصى"
},
"toast": {
"updated": "تم تحديث المستخدم",
@@ -1401,5 +1434,20 @@
"settings": "الإعدادات",
"sign_out": "تسجيل الخروج"
}
},
"sign_out": {
"title": "تسجيل الخروج",
"desc": "هل أنت متأكد أنك تريد تسجيل الخروج؟ ستحتاج إلى تسجيل الدخول مرة أخرى للوصول إلى حسابك.",
"confirm": "تسجيل الخروج"
},
"restore_message": {
"title": "استعادة الرسائل",
"desc": "سيقوم هذا الإجراء بإعادة رفع الرسائل المختارة من Bichon واستعادتها إلى صناديق البريد المقابلة لها على خادم IMAP.",
"confirm": "تنفيذ الاستعادة",
"restore_to_imap": "استعادة البريد",
"success": "تمت استعادة الرسائل بنجاح",
"successDesc": "تمت استعادة الرسائل المختارة إلى خادم IMAP بنجاح.",
"failed": "فشلت استعادة الرسائل",
"failedTitle": "فشل الاستعادة"
}
}

View File

@@ -718,6 +718,39 @@
"interval": "Interval"
},
"settings": {
"appearance": {
"title": "Udseende",
"field": {
"language": "Grænsefladesprog",
"theme": "Grænsefladetema"
},
"description": {
"language": "Vælg det sprog, der skal bruges i webbrugerfladen.",
"theme": "Vælg mellem lys eller mørk tilstand for brugerfladen."
},
"placeholder": {
"select_language": "Vælg sprog"
},
"command": {
"search": "Søg efter sprog...",
"no_results": "Intet sprog fundet."
},
"theme": {
"light": "Lys",
"dark": "Mørk"
},
"button": {
"update": "Opdater indstillinger"
},
"validation": {
"language": {
"required": "Vælg venligst et sprog."
},
"theme": {
"required": "Vælg venligst et tema."
}
}
},
"title": "Indstillinger",
"general": "Generelt",
"proxy": "Proxy",
@@ -829,7 +862,7 @@
},
"password": {
"min": "Adgangskoden skal være mindst 8 tegn",
"max": "Adgangskoden må højst være 32 tegn"
"max": "Adgangskoden må højst være 256 tegn"
}
}
}
@@ -1238,7 +1271,7 @@
"ip_invalid": "Ugyldig IP-adresse",
"password_required": "Adgangskode er påkrævet",
"password_min": "Mindst 8 tegn",
"password_max": "Højst 32 tegn"
"password_max": "Højst 256 tegn"
},
"toast": {
"updated": "Bruger opdateret",
@@ -1401,5 +1434,20 @@
"settings": "Indstillinger",
"sign_out": "Log ud"
}
},
"sign_out": {
"title": "Log ud",
"desc": "Er du sikker på, at du vil logge ud? Du skal logge ind igen for at få adgang til din konto.",
"confirm": "Log ud"
},
"restore_message": {
"title": "Gendan meddelelser",
"desc": "Denne handling vil uploade de valgte meddelelser fra Bichon og gendanne dem til deres tilsvarende postkasser på IMAP-serveren.",
"confirm": "Gendan",
"restore_to_imap": "Gendan e-mail",
"success": "Meddelelser gendannet",
"successDesc": "De valgte meddelelser er blevet gendannet til IMAP-serveren.",
"failed": "Kunne ikke gendanne meddelelser",
"failedTitle": "Gendannelse mislykkedes"
}
}

View File

@@ -718,6 +718,39 @@
"interval": "Intervall"
},
"settings": {
"appearance": {
"title": "Erscheinungsbild",
"field": {
"language": "Sprache der Benutzeroberfläche",
"theme": "Design"
},
"description": {
"language": "Wählen Sie die Sprache für die Web-Oberfläche.",
"theme": "Wählen Sie zwischen hellem oder dunklem Modus."
},
"placeholder": {
"select_language": "Sprache wählen"
},
"command": {
"search": "Sprache suchen...",
"no_results": "Keine Sprache gefunden."
},
"theme": {
"light": "Hell",
"dark": "Dunkel"
},
"button": {
"update": "Einstellungen aktualisieren"
},
"validation": {
"language": {
"required": "Bitte wählen Sie eine Sprache aus."
},
"theme": {
"required": "Bitte wählen Sie ein Thema aus."
}
}
},
"title": "Einstellungen",
"general": "Allgemein",
"proxy": "Proxy",
@@ -829,7 +862,7 @@
},
"password": {
"min": "Das Passwort muss mindestens 8 Zeichen lang sein",
"max": "Das Passwort darf höchstens 32 Zeichen lang sein"
"max": "Das Passwort darf höchstens 256 Zeichen lang sein"
}
}
}
@@ -1238,7 +1271,7 @@
"ip_invalid": "Ungültige IP-Adresse erkannt",
"password_required": "Passwort ist erforderlich",
"password_min": "Passwort muss mindestens 8 Zeichen lang sein",
"password_max": "Passwort darf maximal 32 Zeichen lang sein"
"password_max": "Passwort darf maximal 256 Zeichen lang sein"
},
"toast": {
"updated": "Benutzer aktualisiert",
@@ -1401,5 +1434,20 @@
"settings": "Einstellungen",
"sign_out": "Abmelden"
}
},
"sign_out": {
"title": "Abmelden",
"desc": "Möchten Sie sich wirklich abmelden? Sie müssen sich erneut anmelden, um auf Ihr Konto zuzugreifen.",
"confirm": "Abmelden"
},
"restore_message": {
"title": "Nachrichten wiederherstellen",
"desc": "Diese Aktion lädt die ausgewählten Nachrichten von Bichon hoch und stellt sie in den entsprechenden Postfächern auf dem IMAP-Server wieder her.",
"confirm": "Wiederherstellen",
"restore_to_imap": "E-Mail wiederherstellen",
"success": "Nachrichten wiederhergestellt",
"successDesc": "Die ausgewählten Nachrichten wurden erfolgreich auf dem IMAP-Server wiederhergestellt.",
"failed": "Wiederherstellung fehlgeschlagen",
"failedTitle": "Fehler bei der Wiederherstellung"
}
}

View File

@@ -718,6 +718,39 @@
"interval": "Interval"
},
"settings": {
"appearance": {
"title": "Appearance",
"field": {
"language": "Language",
"theme": "Interface Theme"
},
"description": {
"language": "Select the language used in the Web UI.",
"theme": "Choose between light or dark mode for the interface."
},
"placeholder": {
"select_language": "Select language"
},
"command": {
"search": "Search language...",
"no_results": "No language found."
},
"theme": {
"light": "Light",
"dark": "Dark"
},
"button": {
"update": "Update preferences"
},
"validation": {
"language": {
"required": "Please select a language."
},
"theme": {
"required": "Please select a theme."
}
}
},
"title": "Settings",
"general": "General",
"proxy": "Proxy",
@@ -829,7 +862,7 @@
},
"password": {
"min": "Password must be at least 8 characters",
"max": "Password must be at most 32 characters"
"max": "Password must be at most 256 characters"
}
}
}
@@ -1238,7 +1271,7 @@
"ip_invalid": "Invalid IP address detected",
"password_required": "Password is required",
"password_min": "Password must be at least 8 characters",
"password_max": "Password cannot exceed 32 characters"
"password_max": "Password cannot exceed 256 characters"
},
"toast": {
"updated": "User updated",
@@ -1401,5 +1434,20 @@
"settings": "Settings",
"sign_out": "Sign out"
}
},
"sign_out": {
"title": "Sign out",
"desc": "Are you sure you want to sign out? You will need to sign in again to access your account.",
"confirm": "Sign out"
},
"restore_message": {
"title": "Restore Messages",
"desc": "This action will append the selected messages from Bichon back to their corresponding mailboxes on the IMAP server.",
"confirm": "Restore",
"restore_to_imap": "Restore Mail",
"success": "Messages Restored",
"successDesc": "The selected messages have been successfully restored to the IMAP server.",
"failed": "Failed to restore messages",
"failedTitle": "Restore Failed"
}
}

View File

@@ -718,6 +718,39 @@
"interval": "Intervalo"
},
"settings": {
"appearance": {
"title": "Apariencia",
"field": {
"language": "Idioma de la interfaz",
"theme": "Tema de la interfaz"
},
"description": {
"language": "Seleccione el idioma de la interfaz web.",
"theme": "Elija entre el modo claro u oscuro."
},
"placeholder": {
"select_language": "Seleccionar idioma"
},
"command": {
"search": "Buscar idioma...",
"no_results": "No se encontró el idioma."
},
"theme": {
"light": "Claro",
"dark": "Oscuro"
},
"button": {
"update": "Actualizar preferencias"
},
"validation": {
"language": {
"required": "Por favor, seleccione un idioma."
},
"theme": {
"required": "Por favor, seleccione un tema."
}
}
},
"title": "Configuración",
"general": "General",
"proxy": "Proxy",
@@ -829,7 +862,7 @@
},
"password": {
"min": "La contraseña debe tener al menos 8 caracteres",
"max": "La contraseña debe tener como máximo 32 caracteres"
"max": "La contraseña debe tener como máximo 256 caracteres"
}
}
}
@@ -1238,7 +1271,7 @@
"ip_invalid": "Dirección IP inválida",
"password_required": "La contraseña es obligatoria",
"password_min": "Mínimo 8 caracteres",
"password_max": "Máximo 32 caracteres"
"password_max": "Máximo 256 caracteres"
},
"toast": {
"updated": "Usuario actualizado",
@@ -1401,5 +1434,20 @@
"settings": "Configuración",
"sign_out": "Cerrar sesión"
}
},
"sign_out": {
"title": "Cerrar sesión",
"desc": "¿Está seguro de que desea cerrar sesión? Necesitará iniciar sesión nuevamente para acceder a su cuenta.",
"confirm": "Cerrar sesión"
},
"restore_message": {
"title": "Restaurar mensajes",
"desc": "Esta acción volverá a cargar los mensajes seleccionados de Bichon y los restaurará en sus carpetas correspondientes en el servidor IMAP.",
"confirm": "Restaurar",
"restore_to_imap": "Restaurar correo",
"success": "Mensajes restaurados",
"successDesc": "Los mensajes seleccionados se han restaurado correctamente en el servidor IMAP.",
"failed": "Error al restaurar los mensajes",
"failedTitle": "Error de restauración"
}
}

View File

@@ -718,6 +718,39 @@
"interval": "Väli"
},
"settings": {
"appearance": {
"title": "Ulkoasu",
"field": {
"language": "Käyttöliittymän kieli",
"theme": "Käyttöliittymän teema"
},
"description": {
"language": "Valitse Web-käyttöliittymässä käytettävä kieli.",
"theme": "Valitse käyttöliittymän vaalea tai tumma tila."
},
"placeholder": {
"select_language": "Valitse kieli"
},
"command": {
"search": "Hae kieltä...",
"no_results": "Kieltä ei löytynyt."
},
"theme": {
"light": "Vaalea",
"dark": "Tumma"
},
"button": {
"update": "Päivitä asetukset"
},
"validation": {
"language": {
"required": "Valitse kieli."
},
"theme": {
"required": "Valitse teema."
}
}
},
"title": "Asetukset",
"general": "Yleinen",
"proxy": "Välityspalvelin",
@@ -829,7 +862,7 @@
},
"password": {
"min": "Salasanan on oltava vähintään 8 merkkiä",
"max": "Salasana voi olla enintään 32 merkkiä"
"max": "Salasana voi olla enintään 256 merkkiä"
}
}
}
@@ -1238,7 +1271,7 @@
"ip_invalid": "Virheellinen IP-osoite",
"password_required": "Salasana on pakollinen",
"password_min": "Vähintään 8 merkkiä",
"password_max": "Enintään 32 merkkiä"
"password_max": "Enintään 256 merkkiä"
},
"toast": {
"updated": "Käyttäjä päivitetty",
@@ -1401,5 +1434,20 @@
"settings": "Asetukset",
"sign_out": "Kirjaudu ulos"
}
},
"sign_out": {
"title": "Kirjaudu ulos",
"desc": "Haluatko varmasti kirjautua ulos? Sinun täytyy kirjautua uudelleen päästäksesi tilillesi.",
"confirm": "Kirjaudu ulos"
},
"restore_message": {
"title": "Palauta viestit",
"desc": "Tämä toiminto lataa valitut viestit Bichonista ja palauttaa ne vastaaviin postilaatikoihin IMAP-palvelimella.",
"confirm": "Palauta",
"restore_to_imap": "Palauta sähköposti",
"success": "Viestit palautettu",
"successDesc": "Valitut viestit on palautettu onnistuneesti IMAP-palvelimelle.",
"failed": "Viestien palautus epäonnistui",
"failedTitle": "Palautus epäonnistui"
}
}

View File

@@ -718,6 +718,39 @@
"interval": "Intervalle"
},
"settings": {
"appearance": {
"title": "Apparence",
"field": {
"language": "Langue de l'interface",
"theme": "Thème de l'interface"
},
"description": {
"language": "Choisissez la langue de l'interface web.",
"theme": "Choisissez entre le mode clair ou sombre."
},
"placeholder": {
"select_language": "Choisir une langue"
},
"command": {
"search": "Rechercher une langue...",
"no_results": "Aucune langue trouvée."
},
"theme": {
"light": "Clair",
"dark": "Sombre"
},
"button": {
"update": "Mettre à jour les préférences"
},
"validation": {
"language": {
"required": "Veuillez sélectionner une langue."
},
"theme": {
"required": "Veuillez sélectionner un thème."
}
}
},
"title": "Paramètres",
"general": "Général",
"proxy": "Proxy",
@@ -829,7 +862,7 @@
},
"password": {
"min": "Le mot de passe doit contenir au moins 8 caractères",
"max": "Le mot de passe doit contenir au maximum 32 caractères"
"max": "Le mot de passe doit contenir au maximum 256 caractères"
}
}
}
@@ -1238,7 +1271,7 @@
"ip_invalid": "Adresse IP invalide détectée",
"password_required": "Le mot de passe est requis",
"password_min": "Le mot de passe doit contenir au moins 8 caractères",
"password_max": "Le mot de passe ne peut pas dépasser 32 caractères"
"password_max": "Le mot de passe ne peut pas dépasser 256 caractères"
},
"toast": {
"updated": "Utilisateur mis à jour",
@@ -1401,5 +1434,20 @@
"settings": "Paramètres",
"sign_out": "Se déconnecter"
}
},
"sign_out": {
"title": "Déconnexion",
"desc": "Êtes-vous sûr de vouloir vous déconnecter ? Vous devrez vous reconnecter pour accéder à votre compte.",
"confirm": "Déconnexion"
},
"restore_message": {
"title": "Restaurer les messages",
"desc": "Cette action téléchargera les messages sélectionnés depuis Bichon et les restaurera dans leurs boîtes aux lettres correspondantes sur le serveur IMAP.",
"confirm": "Restaurer",
"restore_to_imap": "Restaurer le courrier",
"success": "Messages restaurés",
"successDesc": "Les messages sélectionnés ont été restaurés avec succès sur le serveur IMAP.",
"failed": "Échec de la restauration des messages",
"failedTitle": "Échec de la restauration"
}
}

View File

@@ -718,6 +718,39 @@
"interval": "Intervallo"
},
"settings": {
"appearance": {
"title": "Aspetto",
"field": {
"language": "Lingua dell'interfaccia",
"theme": "Tema dell'interfaccia"
},
"description": {
"language": "Seleziona la lingua per l'interfaccia web.",
"theme": "Scegli tra la modalità chiara o scura."
},
"placeholder": {
"select_language": "Seleziona lingua"
},
"command": {
"search": "Cerca lingua...",
"no_results": "Nessuna lingua trovata."
},
"theme": {
"light": "Chiaro",
"dark": "Scuro"
},
"button": {
"update": "Aggiorna preferenze"
},
"validation": {
"language": {
"required": "Seleziona una lingua."
},
"theme": {
"required": "Seleziona un tema."
}
}
},
"title": "Impostazioni",
"general": "Generale",
"proxy": "Proxy",
@@ -829,7 +862,7 @@
},
"password": {
"min": "La password deve contenere almeno 8 caratteri",
"max": "La password deve contenere al massimo 32 caratteri"
"max": "La password deve contenere al massimo 256 caratteri"
}
}
}
@@ -1238,7 +1271,7 @@
"ip_invalid": "Indirizzo IP non valido",
"password_required": "La password è obbligatoria",
"password_min": "Minimo 8 caratteri",
"password_max": "Massimo 32 caratteri"
"password_max": "Massimo 256 caratteri"
},
"toast": {
"updated": "Utente aggiornato",
@@ -1401,5 +1434,20 @@
"settings": "Impostazioni",
"sign_out": "Disconnetti"
}
},
"sign_out": {
"title": "Disconnetti",
"desc": "Sei sicuro di voler disconnetterti? Dovrai accedere di nuovo per usare il tuo account.",
"confirm": "Disconnetti"
},
"restore_message": {
"title": "Ripristina messaggi",
"desc": "Questa azione caricherà i messaggi selezionati da Bichon e li ripristinerà nelle rispettive caselle di posta sul server IMAP.",
"confirm": "Ripristina",
"restore_to_imap": "Ripristina posta",
"success": "Messaggi ripristinati",
"successDesc": "I messaggi selezionati sono stati ripristinati con successo sul server IMAP.",
"failed": "Impossibile ripristinare i messaggi",
"failedTitle": "Ripristino fallito"
}
}

View File

@@ -718,6 +718,39 @@
"interval": "間隔"
},
"settings": {
"appearance": {
"title": "外観",
"field": {
"language": "表示言語",
"theme": "インターフェーステーマ"
},
"description": {
"language": "Web UIで使用する言語を選択します。",
"theme": "ライトモードまたはダークモードを選択します。"
},
"placeholder": {
"select_language": "言語を選択"
},
"command": {
"search": "言語を検索...",
"no_results": "言語が見つかりません。"
},
"theme": {
"light": "ライト",
"dark": "ダーク"
},
"button": {
"update": "設定を更新"
},
"validation": {
"language": {
"required": "言語を選択してください。"
},
"theme": {
"required": "テーマを選択してください。"
}
}
},
"title": "設定",
"general": "一般",
"proxy": "プロキシ",
@@ -829,7 +862,7 @@
},
"password": {
"min": "パスワードは8文字以上である必要があります",
"max": "パスワードは32文字以内である必要があります"
"max": "パスワードは256文字以内である必要があります"
}
}
}
@@ -1238,7 +1271,7 @@
"ip_invalid": "無効な IP アドレスが検出されました",
"password_required": "パスワードは必須です",
"password_min": "パスワードは 8 文字以上である必要があります",
"password_max": "パスワードは 32 文字以内である必要があります"
"password_max": "パスワードは 256 文字以内である必要があります"
},
"toast": {
"updated": "ユーザーを更新しました",
@@ -1401,5 +1434,20 @@
"settings": "設定",
"sign_out": "ログアウト"
}
},
"sign_out": {
"title": "サインアウト",
"desc": "本当にサインアウトしますか? アカウントにアクセスするには再度サインインが必要です。",
"confirm": "サインアウト"
},
"restore_message": {
"title": "メッセージを復元",
"desc": "この操作により、選択したメッセージをBichonから再アップロードし、IMAPサーバー上の対応するメールボックスに復元します。",
"confirm": "復元を実行",
"restore_to_imap": "メールを復元",
"success": "メッセージを復元しました",
"successDesc": "選択したメッセージがIMAPサーバーに正常に復元されました。",
"failed": "メッセージの復元に失敗しました",
"failedTitle": "復元失敗"
}
}

View File

@@ -718,6 +718,39 @@
"interval": "간격"
},
"settings": {
"appearance": {
"title": "외관",
"field": {
"language": "인터페이스 언어",
"theme": "인터페이스 테마"
},
"description": {
"language": "웹 UI에서 사용할 언어를 선택하세요.",
"theme": "라이트 모드와 다크 모드 중에서 선택하세요."
},
"placeholder": {
"select_language": "언어 선택"
},
"command": {
"search": "언어 검색...",
"no_results": "언어를 찾을 수 없습니다."
},
"theme": {
"light": "라이트",
"dark": "다크"
},
"button": {
"update": "설정 업데이트"
},
"validation": {
"language": {
"required": "언어를 선택해주세요."
},
"theme": {
"required": "테마를 선택해주세요."
}
}
},
"title": "설정",
"general": "일반",
"proxy": "프록시",
@@ -829,7 +862,7 @@
},
"password": {
"min": "비밀번호는 최소 8자 이상이어야 합니다",
"max": "비밀번호는 최대 32자까지 가능합니다"
"max": "비밀번호는 최대 256자까지 가능합니다"
}
}
}
@@ -1238,7 +1271,7 @@
"ip_invalid": "유효하지 않은 IP 주소가 감지되었습니다",
"password_required": "비밀번호는 필수입니다",
"password_min": "비밀번호는 8자 이상이어야 합니다",
"password_max": "비밀번호는 32자를 초과할 수 없습니다"
"password_max": "비밀번호는 256자를 초과할 수 없습니다"
},
"toast": {
"updated": "사용자 업데이트됨",
@@ -1401,5 +1434,20 @@
"settings": "설정",
"sign_out": "로그아웃"
}
},
"sign_out": {
"title": "로그아웃",
"desc": "로그아웃하시겠습니까? 계정에 접근하려면 다시 로그인해야 합니다.",
"confirm": "로그아웃"
},
"restore_message": {
"title": "메시지 복원",
"desc": "이 작업은 선택한 메시지를 Bichon에서 다시 업로드하여 IMAP 서버의 해당 사서함으로 복원합니다.",
"confirm": "복원 실행",
"restore_to_imap": "메일 복원",
"success": "메시지 복원 완료",
"successDesc": "선택한 메시지가 IMAP 서버로 성공적으로 복원되었습니다.",
"failed": "메시지 복원 실패",
"failedTitle": "복원 실패"
}
}

View File

@@ -718,6 +718,39 @@
"interval": "Interval"
},
"settings": {
"appearance": {
"title": "Uiterlijk",
"field": {
"language": "Interfacetaal",
"theme": "Interfacethema"
},
"description": {
"language": "Selecteer de taal voor de webinterface.",
"theme": "Kies tussen de lichte of donkere modus voor de interface."
},
"placeholder": {
"select_language": "Selecteer taal"
},
"command": {
"search": "Taal zoeken...",
"no_results": "Geen taal gevonden."
},
"theme": {
"light": "Licht",
"dark": "Donker"
},
"button": {
"update": "Voorkeuren bijwerken"
},
"validation": {
"language": {
"required": "Selecteer een taal."
},
"theme": {
"required": "Selecteer een thema."
}
}
},
"title": "Instellingen",
"general": "Algemeen",
"proxy": "Proxy",
@@ -829,7 +862,7 @@
},
"password": {
"min": "Wachtwoord moet minimaal 8 tekens bevatten",
"max": "Wachtwoord mag maximaal 32 tekens bevatten"
"max": "Wachtwoord mag maximaal 256 tekens bevatten"
}
}
}
@@ -1238,7 +1271,7 @@
"ip_invalid": "Ongeldig IP-adres gedetecteerd",
"password_required": "Wachtwoord is verplicht",
"password_min": "Minimaal 8 tekens",
"password_max": "Maximaal 32 tekens"
"password_max": "Maximaal 256 tekens"
},
"toast": {
"updated": "Gebruiker bijgewerkt",
@@ -1401,5 +1434,20 @@
"settings": "Instellingen",
"sign_out": "Uitloggen"
}
},
"sign_out": {
"title": "Uitloggen",
"desc": "Weet je zeker dat je wilt uitloggen? Je moet opnieuw inloggen om toegang te krijgen tot je account.",
"confirm": "Uitloggen"
},
"restore_message": {
"title": "Berichten herstellen",
"desc": "Deze actie uploadt de geselecteerde berichten van Bichon en herstelt ze in de bijbehorende mailboxen op de IMAP-server.",
"confirm": "Herstellen",
"restore_to_imap": "E-mail herstellen",
"success": "Berichten hersteld",
"successDesc": "De geselecteerde berichten zijn succesvol hersteld op de IMAP-server.",
"failed": "Herstellen van berichten mislukt",
"failedTitle": "Herstel mislukt"
}
}

View File

@@ -718,6 +718,39 @@
"interval": "Intervall"
},
"settings": {
"appearance": {
"title": "Utseende",
"field": {
"language": "Grensesnittspråk",
"theme": "Grensesnittema"
},
"description": {
"language": "Velg språket som skal brukes i webgrensesnittet.",
"theme": "Velg mellom lys eller mørk modus for grensesnittet."
},
"placeholder": {
"select_language": "Velg språk"
},
"command": {
"search": "Søk etter språk...",
"no_results": "Fant ingen språk."
},
"theme": {
"light": "Lys",
"dark": "Mørk"
},
"button": {
"update": "Oppdater innstillinger"
},
"validation": {
"language": {
"required": "Vennligst velg et språk."
},
"theme": {
"required": "Vennligst velg et tema."
}
}
},
"title": "Innstillinger",
"general": "Generelt",
"proxy": "Proxy",
@@ -829,7 +862,7 @@
},
"password": {
"min": "Passordet må være minst 8 tegn",
"max": "Passordet kan være maks 32 tegn"
"max": "Passordet kan være maks 256 tegn"
}
}
}
@@ -1238,7 +1271,7 @@
"ip_invalid": "Ugyldig IP-adresse",
"password_required": "Passord er påkrevd",
"password_min": "Minst 8 tegn",
"password_max": "Maks 32 tegn"
"password_max": "Maks 256 tegn"
},
"toast": {
"updated": "Bruker oppdatert",
@@ -1401,5 +1434,20 @@
"settings": "Innstillinger",
"sign_out": "Logg ut"
}
},
"sign_out": {
"title": "Logg ut",
"desc": "Er du sikker på at du vil logge ut? Du må logge inn igjen for å få tilgang til kontoen din.",
"confirm": "Logg ut"
},
"restore_message": {
"title": "Gjenopprett meldinger",
"desc": "Denne handlingen vil laste opp de valgte meldingene fra Bichon og gjenopprette dem til deres tilsvarende postbokser på IMAP-serveren.",
"confirm": "Gjenopprett",
"restore_to_imap": "Gjenopprett e-post",
"success": "Meldinger gjenopprettet",
"successDesc": "De valgte meldingene har blitt gjenopprettet til IMAP-serveren.",
"failed": "Kunne ikke gjenopprette meldinger",
"failedTitle": "Gjenoppretting mislyktes"
}
}

View File

@@ -718,6 +718,39 @@
"interval": "Interval"
},
"settings": {
"appearance": {
"title": "Wygląd",
"field": {
"language": "Język interfejsu",
"theme": "Motyw interfejsu"
},
"description": {
"language": "Wybierz język interfejsu webowego.",
"theme": "Wybierz tryb jasny lub ciemny."
},
"placeholder": {
"select_language": "Wybierz język"
},
"command": {
"search": "Szukaj języka...",
"no_results": "Nie znaleziono języka."
},
"theme": {
"light": "Jasny",
"dark": "Ciemny"
},
"button": {
"update": "Aktualizuj preferencje"
},
"validation": {
"language": {
"required": "Proszę wybrać język."
},
"theme": {
"required": "Proszę wybrać motyw."
}
}
},
"title": "Ustawienia",
"general": "Ogólne",
"proxy": "Proxy",
@@ -829,7 +862,7 @@
},
"password": {
"min": "Hasło musi mieć co najmniej 8 znaków",
"max": "Hasło może mieć maksymalnie 32 znaki"
"max": "Hasło może mieć maksymalnie 256 znaki"
}
}
}
@@ -1238,7 +1271,7 @@
"ip_invalid": "Wykryto niepoprawny adres IP",
"password_required": "Hasło jest wymagane",
"password_min": "Min. 8 znaków",
"password_max": "Maks. 32 znaki"
"password_max": "Maks. 256 znaki"
},
"toast": {
"updated": "Użytkownik zaktualizowany",
@@ -1401,5 +1434,20 @@
"settings": "Ustawienia",
"sign_out": "Wyloguj się"
}
},
"sign_out": {
"title": "Wyloguj się",
"desc": "Czy na pewno chcesz się wylogować? Aby uzyskać dostęp do konta, będziesz musiał zalogować się ponownie.",
"confirm": "Wyloguj się"
},
"restore_message": {
"title": "Przywróć wiadomości",
"desc": "Ta operacja prześle wybrane wiadomości z Bichon i przywróci je do odpowiednich skrzynek pocztowych na serwerze IMAP.",
"confirm": "Przywróć",
"restore_to_imap": "Przywróć pocztę",
"success": "Wiadomości przywrócone",
"successDesc": "Wybrane wiadomości zostały pomyślnie przywrócone na serwer IMAP.",
"failed": "Nie udało się przywrócić wiadomości",
"failedTitle": "Przywracanie nie powiodło się"
}
}

View File

@@ -718,6 +718,39 @@
"interval": "Intervalo"
},
"settings": {
"appearance": {
"title": "Aparência",
"field": {
"language": "Idioma da interface",
"theme": "Tema da interface"
},
"description": {
"language": "Selecione o idioma da interface web.",
"theme": "Escolha entre o modo claro ou escuro."
},
"placeholder": {
"select_language": "Selecionar idioma"
},
"command": {
"search": "Pesquisar idioma...",
"no_results": "Nenhum idioma encontrado."
},
"theme": {
"light": "Claro",
"dark": "Escuro"
},
"button": {
"update": "Atualizar preferências"
},
"validation": {
"language": {
"required": "Por favor, selecione um idioma."
},
"theme": {
"required": "Por favor, selecione um tema."
}
}
},
"title": "Configurações",
"general": "Geral",
"proxy": "Proxy",
@@ -829,7 +862,7 @@
},
"password": {
"min": "A senha deve ter pelo menos 8 caracteres",
"max": "A senha deve ter no máximo 32 caracteres"
"max": "A senha deve ter no máximo 256 caracteres"
}
}
}
@@ -1238,7 +1271,7 @@
"ip_invalid": "IP inválido detectado",
"password_required": "Senha é obrigatória",
"password_min": "Mínimo 8 caracteres",
"password_max": "Máximo 32 caracteres"
"password_max": "Máximo 256 caracteres"
},
"toast": {
"updated": "Usuário atualizado",
@@ -1401,5 +1434,20 @@
"settings": "Configurações",
"sign_out": "Sair"
}
},
"sign_out": {
"title": "Sair",
"desc": "Tem certeza de que deseja sair? Você precisará entrar novamente para acessar sua conta.",
"confirm": "Sair"
},
"restore_message": {
"title": "Restaurar mensagens",
"desc": "Esta ação irá carregar as mensagens selecionadas do Bichon e restaurá-las nas respetivas caixas de correio no servidor IMAP.",
"confirm": "Restaurar",
"restore_to_imap": "Restaurar e-mail",
"success": "Mensagens restauradas",
"successDesc": "As mensagens selecionadas foram restauradas com sucesso para o servidor IMAP.",
"failed": "Falha ao restaurar mensagens",
"failedTitle": "Falha na restauração"
}
}

View File

@@ -718,6 +718,39 @@
"interval": "Интервал"
},
"settings": {
"appearance": {
"title": "Внешний вид",
"field": {
"language": "Язык интерфейса",
"theme": "Тема интерфейса"
},
"description": {
"language": "Выберите язык веб-интерфейса.",
"theme": "Выберите светлый или темный режим."
},
"placeholder": {
"select_language": "Выберите язык"
},
"command": {
"search": "Поиск языка...",
"no_results": "Язык не найден."
},
"theme": {
"light": "Светлая",
"dark": "Темная"
},
"button": {
"update": "Обновить настройки"
},
"validation": {
"language": {
"required": "Пожалуйста, выберите язык."
},
"theme": {
"required": "Пожалуйста, выберите тему."
}
}
},
"title": "Настройки",
"general": "Общие",
"proxy": "Прокси",
@@ -829,7 +862,7 @@
},
"password": {
"min": "Пароль должен содержать не менее 8 символов",
"max": "Пароль должен содержать не более 32 символов"
"max": "Пароль должен содержать не более 256 символов"
}
}
}
@@ -1238,7 +1271,7 @@
"ip_invalid": "Неверный IP-адрес",
"password_required": "Пароль обязателен",
"password_min": "Минимум 8 символов",
"password_max": "Максимум 32 символа"
"password_max": "Максимум 256 символа"
},
"toast": {
"updated": "Пользователь обновлен",
@@ -1401,5 +1434,20 @@
"settings": "Настройки",
"sign_out": "Выйти"
}
},
"sign_out": {
"title": "Выйти",
"desc": "Вы уверены, что хотите выйти? Вам нужно будет снова войти, чтобы получить доступ к аккаунту.",
"confirm": "Выйти"
},
"restore_message": {
"title": "Восстановить сообщения",
"desc": "Это действие загрузит выбранные сообщения из Bichon и восстановит их в соответствующих почтовых ящиках на IMAP-сервере.",
"confirm": "Восстановить",
"restore_to_imap": "Восстановить почту",
"success": "Сообщения восстановлены",
"successDesc": "Выбранные сообщения были успешно восстановлены на IMAP-сервере.",
"failed": "Не удалось восстановить сообщения",
"failedTitle": "Ошибка восстановления"
}
}

View File

@@ -718,6 +718,39 @@
"interval": "Intervall"
},
"settings": {
"appearance": {
"title": "Utseende",
"field": {
"language": "Gränssnittsspråk",
"theme": "Gränssnittstema"
},
"description": {
"language": "Välj språk för webbanvändargränssnittet.",
"theme": "Välj mellan ljust eller mörkt läge för gränssnittet."
},
"placeholder": {
"select_language": "Välj språk"
},
"command": {
"search": "Sök språk...",
"no_results": "Inget språk hittades."
},
"theme": {
"light": "Ljust",
"dark": "Mörkt"
},
"button": {
"update": "Uppdatera inställningar"
},
"validation": {
"language": {
"required": "Välj ett språk."
},
"theme": {
"required": "Välj ett tema."
}
}
},
"title": "Inställningar",
"general": "Allmänt",
"proxy": "Proxy",
@@ -829,7 +862,7 @@
},
"password": {
"min": "Lösenordet måste vara minst 8 tecken",
"max": "Lösenordet får vara högst 32 tecken"
"max": "Lösenordet får vara högst 256 tecken"
}
}
}
@@ -1238,7 +1271,7 @@
"ip_invalid": "Ogiltig IP-adress upptäckt",
"password_required": "Lösenord krävs",
"password_min": "Lösenordet måste vara minst 8 tecken",
"password_max": "Lösenordet får vara max 32 tecken"
"password_max": "Lösenordet får vara max 256 tecken"
},
"toast": {
"updated": "Användaren uppdaterad",
@@ -1401,5 +1434,20 @@
"settings": "Inställningar",
"sign_out": "Logga ut"
}
},
"sign_out": {
"title": "Logga ut",
"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.",
"confirm": "Logga ut"
},
"restore_message": {
"title": "Återställ meddelanden",
"desc": "Denna åtgärd kommer att ladda upp de valda meddelandena från Bichon och återställa dem till deras motsvarande brevlådor på IMAP-servern.",
"confirm": "Återställ",
"restore_to_imap": "Återställ e-post",
"success": "Meddelanden återställda",
"successDesc": "De valda meddelandena har återställts till IMAP-servern.",
"failed": "Misslyckades med att återställa meddelanden",
"failedTitle": "Återställning misslyckades"
}
}

View File

@@ -398,6 +398,13 @@
}
},
"search": {
"size": "大小",
"any": "不限",
"tiny": "很小(<15 KB",
"small": "小(<2 MB",
"medium": "中(<20 MB",
"large": "大≥20 MB",
"sizeDescription": "大小指的是整封郵件的大小,包含附件。",
"title": "搜尋",
"searching": "正在搜尋,請稍候...",
"noEmailsFound": "找不到任何郵件",
@@ -711,6 +718,39 @@
"interval": "間隔"
},
"settings": {
"appearance": {
"title": "外觀設置",
"field": {
"language": "介面语言",
"theme": "介面主題"
},
"description": {
"language": "選擇網頁介面顯示的语言。",
"theme": "為介面選擇淺色或深色顯示模式。"
},
"placeholder": {
"select_language": "選擇語言"
},
"command": {
"search": "搜尋語言...",
"no_results": "未找到相關語言。"
},
"theme": {
"light": "淺色",
"dark": "深色"
},
"button": {
"update": "更新偏好設置"
},
"validation": {
"language": {
"required": "請選擇一種語言。"
},
"theme": {
"required": "請選擇一個主題。"
}
}
},
"title": "設定",
"general": "一般",
"proxy": "代理",
@@ -822,7 +862,7 @@
},
"password": {
"min": "密碼至少需要 8 個字元",
"max": "密碼最多 32 個字元"
"max": "密碼最多 256 個字元"
}
}
}
@@ -1231,7 +1271,7 @@
"ip_invalid": "發現無效的 IP 地址",
"password_required": "密碼不能為空",
"password_min": "密碼長度至少為 8 個字元",
"password_max": "密碼長度不能超過 32 個字元"
"password_max": "密碼長度不能超過 256 個字元"
},
"toast": {
"updated": "使用者已更新",
@@ -1394,5 +1434,20 @@
"settings": "設定",
"sign_out": "登出"
}
},
"sign_out": {
"title": "登出",
"desc": "您確定要登出嗎?您需要重新登入才能存取帳戶。",
"confirm": "登出"
},
"restore_message": {
"title": "還原郵件",
"desc": "此操作將把選定的郵件從 Bichon 系統重新上傳並還原到其在 IMAP 伺服器上對應的信箱中。",
"confirm": "執行還原",
"restore_to_imap": "還原郵件",
"success": "郵件還原成功",
"successDesc": "選定的郵件已成功還原到 IMAP 伺服器。",
"failed": "還原郵件失敗",
"failedTitle": "還原失敗"
}
}

View File

@@ -718,6 +718,39 @@
"interval": "间隔"
},
"settings": {
"appearance": {
"title": "外观设置",
"field": {
"language": "语言",
"theme": "界面主题"
},
"description": {
"language": "选择 Web 界面显示的语言。",
"theme": "为界面选择浅色或深色显示模式。"
},
"placeholder": {
"select_language": "选择语言"
},
"command": {
"search": "搜索语言...",
"no_results": "未找到相关语言。"
},
"theme": {
"light": "浅色",
"dark": "深色"
},
"button": {
"update": "更新偏好设置"
},
"validation": {
"language": {
"required": "请选择一种语言。"
},
"theme": {
"required": "请选择一个主题。"
}
}
},
"title": "设置",
"general": "常规",
"proxy": "网络代理",
@@ -829,7 +862,7 @@
},
"password": {
"min": "密码长度至少为 8 个字符",
"max": "密码长度不能超过 32 个字符"
"max": "密码长度不能超过 256 个字符"
}
}
}
@@ -1238,7 +1271,7 @@
"ip_invalid": "发现无效的 IP 地址",
"password_required": "密码不能为空",
"password_min": "密码长度至少为 8 个字符",
"password_max": "密码长度不能超过 32 个字符"
"password_max": "密码长度不能超过 256 个字符"
},
"toast": {
"updated": "用户已更新",
@@ -1401,5 +1434,20 @@
"settings": "设置",
"sign_out": "退出登录"
}
},
"sign_out": {
"title": "退出登录",
"desc": "您确定要退出登录吗?您需要重新登录才能访问账户。",
"confirm": "退出登录"
},
"restore_message": {
"title": "还原邮件",
"desc": "此操作将把选定的邮件从 Bichon 系统重新上传并恢复到其在 IMAP 服务器上对应的邮箱中。",
"confirm": "执行还原",
"restore_to_imap": "还原邮件",
"success": "邮件还原成功",
"successDesc": "选定的邮件已成功恢复到 IMAP 服务器。",
"failed": "还原邮件失败",
"failedTitle": "还原失败"
}
}

View File

@@ -70,6 +70,9 @@ const AuthenticatedSettingsProfileLazyImport = createFileRoute(
const AuthenticatedSettingsConfigurationsLazyImport = createFileRoute(
'/_authenticated/settings/configurations',
)()
const AuthenticatedSettingsAppearanceLazyImport = createFileRoute(
'/_authenticated/settings/appearance',
)()
const AuthenticatedSettingsApiTokensLazyImport = createFileRoute(
'/_authenticated/settings/api-tokens',
)()
@@ -282,6 +285,17 @@ const AuthenticatedSettingsConfigurationsLazyRoute =
),
)
const AuthenticatedSettingsAppearanceLazyRoute =
AuthenticatedSettingsAppearanceLazyImport.update({
id: '/appearance',
path: '/appearance',
getParentRoute: () => AuthenticatedSettingsRouteLazyRoute,
} as any).lazy(() =>
import('./routes/_authenticated/settings/appearance.lazy').then(
(d) => d.Route,
),
)
const AuthenticatedSettingsApiTokensLazyRoute =
AuthenticatedSettingsApiTokensLazyImport.update({
id: '/api-tokens',
@@ -381,6 +395,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthenticatedSettingsApiTokensLazyImport
parentRoute: typeof AuthenticatedSettingsRouteLazyImport
}
'/_authenticated/settings/appearance': {
id: '/_authenticated/settings/appearance'
path: '/appearance'
fullPath: '/settings/appearance'
preLoaderRoute: typeof AuthenticatedSettingsAppearanceLazyImport
parentRoute: typeof AuthenticatedSettingsRouteLazyImport
}
'/_authenticated/settings/configurations': {
id: '/_authenticated/settings/configurations'
path: '/configurations'
@@ -479,6 +500,7 @@ declare module '@tanstack/react-router' {
interface AuthenticatedSettingsRouteLazyRouteChildren {
AuthenticatedSettingsApiTokensLazyRoute: typeof AuthenticatedSettingsApiTokensLazyRoute
AuthenticatedSettingsAppearanceLazyRoute: typeof AuthenticatedSettingsAppearanceLazyRoute
AuthenticatedSettingsConfigurationsLazyRoute: typeof AuthenticatedSettingsConfigurationsLazyRoute
AuthenticatedSettingsProfileLazyRoute: typeof AuthenticatedSettingsProfileLazyRoute
AuthenticatedSettingsProxyLazyRoute: typeof AuthenticatedSettingsProxyLazyRoute
@@ -489,6 +511,8 @@ const AuthenticatedSettingsRouteLazyRouteChildren: AuthenticatedSettingsRouteLaz
{
AuthenticatedSettingsApiTokensLazyRoute:
AuthenticatedSettingsApiTokensLazyRoute,
AuthenticatedSettingsAppearanceLazyRoute:
AuthenticatedSettingsAppearanceLazyRoute,
AuthenticatedSettingsConfigurationsLazyRoute:
AuthenticatedSettingsConfigurationsLazyRoute,
AuthenticatedSettingsProfileLazyRoute:
@@ -562,6 +586,7 @@ export interface FileRoutesByFullPath {
'/503': typeof errors503LazyRoute
'/': typeof AuthenticatedIndexRoute
'/settings/api-tokens': typeof AuthenticatedSettingsApiTokensLazyRoute
'/settings/appearance': typeof AuthenticatedSettingsAppearanceLazyRoute
'/settings/configurations': typeof AuthenticatedSettingsConfigurationsLazyRoute
'/settings/profile': typeof AuthenticatedSettingsProfileLazyRoute
'/settings/proxy': typeof AuthenticatedSettingsProxyLazyRoute
@@ -586,6 +611,7 @@ export interface FileRoutesByTo {
'/503': typeof errors503LazyRoute
'/': typeof AuthenticatedIndexRoute
'/settings/api-tokens': typeof AuthenticatedSettingsApiTokensLazyRoute
'/settings/appearance': typeof AuthenticatedSettingsAppearanceLazyRoute
'/settings/configurations': typeof AuthenticatedSettingsConfigurationsLazyRoute
'/settings/profile': typeof AuthenticatedSettingsProfileLazyRoute
'/settings/proxy': typeof AuthenticatedSettingsProxyLazyRoute
@@ -615,6 +641,7 @@ export interface FileRoutesById {
'/(errors)/503': typeof errors503LazyRoute
'/_authenticated/': typeof AuthenticatedIndexRoute
'/_authenticated/settings/api-tokens': typeof AuthenticatedSettingsApiTokensLazyRoute
'/_authenticated/settings/appearance': typeof AuthenticatedSettingsAppearanceLazyRoute
'/_authenticated/settings/configurations': typeof AuthenticatedSettingsConfigurationsLazyRoute
'/_authenticated/settings/profile': typeof AuthenticatedSettingsProfileLazyRoute
'/_authenticated/settings/proxy': typeof AuthenticatedSettingsProxyLazyRoute
@@ -644,6 +671,7 @@ export interface FileRouteTypes {
| '/503'
| '/'
| '/settings/api-tokens'
| '/settings/appearance'
| '/settings/configurations'
| '/settings/profile'
| '/settings/proxy'
@@ -667,6 +695,7 @@ export interface FileRouteTypes {
| '/503'
| '/'
| '/settings/api-tokens'
| '/settings/appearance'
| '/settings/configurations'
| '/settings/profile'
| '/settings/proxy'
@@ -694,6 +723,7 @@ export interface FileRouteTypes {
| '/(errors)/503'
| '/_authenticated/'
| '/_authenticated/settings/api-tokens'
| '/_authenticated/settings/appearance'
| '/_authenticated/settings/configurations'
| '/_authenticated/settings/profile'
| '/_authenticated/settings/proxy'
@@ -777,6 +807,7 @@ export const routeTree = rootRoute
"parent": "/_authenticated",
"children": [
"/_authenticated/settings/api-tokens",
"/_authenticated/settings/appearance",
"/_authenticated/settings/configurations",
"/_authenticated/settings/profile",
"/_authenticated/settings/proxy",
@@ -815,6 +846,10 @@ export const routeTree = rootRoute
"filePath": "_authenticated/settings/api-tokens.lazy.tsx",
"parent": "/_authenticated/settings"
},
"/_authenticated/settings/appearance": {
"filePath": "_authenticated/settings/appearance.lazy.tsx",
"parent": "/_authenticated/settings"
},
"/_authenticated/settings/configurations": {
"filePath": "_authenticated/settings/configurations.lazy.tsx",
"parent": "/_authenticated/settings"

View File

@@ -0,0 +1,27 @@
//
// Copyright (c) 2025 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 { createLazyFileRoute } from '@tanstack/react-router'
import { SettingsAppearance } from '@/features/settings/appearance'
export const Route = createLazyFileRoute('/_authenticated/settings/appearance')(
{
component: SettingsAppearance,
},
)