mirror of
https://github.com/rustmailer/bichon.git
synced 2026-08-31 01:52:30 +00:00
Compare commits
33 Commits
fix/cli-mb
...
1.2.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d8b78b8010 | ||
|
|
36f3f19cdc | ||
|
|
9f3097df32 | ||
|
|
c075b9ef12 | ||
|
|
21a7f7e9d5 | ||
|
|
26c14fcaaf | ||
|
|
6873841ba4 | ||
|
|
0dd81f599d | ||
|
|
b3afc52a82 | ||
|
|
171a40d70f | ||
|
|
895ea543a9 | ||
|
|
6eca351994 | ||
|
|
1f477eca65 | ||
|
|
a3cdc094e8 | ||
|
|
95147a7824 | ||
|
|
b22811f78c | ||
|
|
fd61d013a2 | ||
|
|
3a950e7591 | ||
|
|
f17820bfa8 | ||
|
|
178b25d27d | ||
|
|
d160ca75f5 | ||
|
|
04136a4ae2 | ||
|
|
1d6f5d9a22 | ||
|
|
105a6d9b15 | ||
|
|
df440c8441 | ||
|
|
4116a59b79 | ||
|
|
609eee1b84 | ||
|
|
79b9f07888 | ||
|
|
ba28369202 | ||
|
|
ff64b66f79 | ||
|
|
a4f8e674c3 | ||
|
|
dde6b990da | ||
|
|
6b1f843bd5 |
26
Cargo.lock
generated
26
Cargo.lock
generated
@@ -293,7 +293,7 @@ checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
||||
|
||||
[[package]]
|
||||
name = "bichon-admin"
|
||||
version = "1.0.2"
|
||||
version = "1.2.0"
|
||||
dependencies = [
|
||||
"bichon-core",
|
||||
"console",
|
||||
@@ -312,7 +312,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "bichon-cli"
|
||||
version = "1.0.2"
|
||||
version = "1.2.0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"bichon-core",
|
||||
@@ -338,7 +338,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "bichon-core"
|
||||
version = "1.0.2"
|
||||
version = "1.2.0"
|
||||
dependencies = [
|
||||
"async-imap",
|
||||
"base64 0.22.1",
|
||||
@@ -396,7 +396,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "bichon-server"
|
||||
version = "1.0.2"
|
||||
version = "1.2.0"
|
||||
dependencies = [
|
||||
"bichon-core",
|
||||
"bichon-smtp",
|
||||
@@ -421,7 +421,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "bichon-smtp"
|
||||
version = "1.0.2"
|
||||
version = "1.2.0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"bichon-core",
|
||||
@@ -1006,9 +1006,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dashmap"
|
||||
version = "6.1.0"
|
||||
version = "6.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5041cc499144891f3790297212f32a74fb938e5136a14943f338ef9e0ae276cf"
|
||||
checksum = "e6361d5c062261c78a176addb82d4c821ae42bed6089de0e12603cd25de2059c"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"crossbeam-utils",
|
||||
@@ -2254,9 +2254,9 @@ checksum = "52ff2c0fe9bc6cb6b14a0592c2ff4fa9ceb83eea9db979b0487cd054946a2b8f"
|
||||
|
||||
[[package]]
|
||||
name = "libmimalloc-sys"
|
||||
version = "0.1.47"
|
||||
version = "0.1.48"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2d1eacfa31c33ec25e873c136ba5669f00f9866d0688bea7be4d3f7e43067df6"
|
||||
checksum = "2892ae4ea6fa2cb7acb0e236a6880d39523239cd9089de71d220910ccc806790"
|
||||
dependencies = [
|
||||
"cc",
|
||||
]
|
||||
@@ -2467,9 +2467,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "mimalloc"
|
||||
version = "0.1.50"
|
||||
version = "0.1.51"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b3627c4272df786b9260cabaa46aec1d59c93ede723d4c3ef646c503816b0640"
|
||||
checksum = "ebca48a43116bc25f18a61360f1be98412f50cc218f5e52c823086b999a4a21a"
|
||||
dependencies = [
|
||||
"libmimalloc-sys",
|
||||
]
|
||||
@@ -4286,9 +4286,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "sysinfo"
|
||||
version = "0.39.1"
|
||||
version = "0.39.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a4deba334e1190ba7cb498327affa11e5ece10d26a30ab2f27fcf09504b8d8b6"
|
||||
checksum = "14311e7e9a03114cd4b65eedd54e8fed2945e17f08586ae97ef53bc0669f9581"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"memchr",
|
||||
|
||||
@@ -11,13 +11,13 @@ members = [
|
||||
resolver = "2"
|
||||
|
||||
[workspace.package]
|
||||
version = "1.0.2"
|
||||
version = "1.2.0"
|
||||
edition = "2021"
|
||||
|
||||
[workspace.dependencies]
|
||||
chrono = "0.4.44"
|
||||
clap = { version = "4.6.1", features = ["derive", "env"] }
|
||||
mimalloc = "0.1.50"
|
||||
mimalloc = "0.1.51"
|
||||
memdb = { path = "crates/memdb" }
|
||||
itertools = "0.14.0"
|
||||
ring = { version = "0.17.14", features = ["std"] }
|
||||
@@ -52,7 +52,7 @@ tokio-rustls = { version = "0.26.4", default-features = false, features = [
|
||||
timeago = "0.6.0"
|
||||
oauth2 = { version = "5.0.0", features = ["reqwest-blocking"] }
|
||||
url = { version = "2.5.8", features = ["serde"] }
|
||||
sysinfo = "0.39.1"
|
||||
sysinfo = "0.39.2"
|
||||
num_cpus = "1.17.0"
|
||||
rand = "0.10.1"
|
||||
encoding_rs = "0.8.35"
|
||||
@@ -73,7 +73,7 @@ time = { version = "0.3.47", features = [
|
||||
rust-embed = "8.11.0"
|
||||
murmur3 = "0.5.2"
|
||||
urlencoding = "2.1.3"
|
||||
dashmap = "6.1.0"
|
||||
dashmap = "6.2.1"
|
||||
gethostname = "1.1.0"
|
||||
itoa = "1.0.18"
|
||||
html2text = "0.17.1"
|
||||
|
||||
@@ -271,6 +271,10 @@ All settings accept both CLI flags (`--bichon-http-port`) and environment variab
|
||||
> [!TIP]
|
||||
> Place `BICHON_INDEX_DIR` on fast SSD storage for responsive search, and `BICHON_DATA_DIR` on high-capacity HDD for cost-effective blob storage.
|
||||
|
||||
|
||||
> [!IMPORTANT]
|
||||
> Bichon does NOT support writing data directly to a network file system (NFS, CIFS/SMB, etc.). All directories — `BICHON_ROOT_DIR`, `BICHON_DATA_DIR`, and `BICHON_INDEX_DIR` — must reside on a **local file system**; otherwise, data corruption may occur.
|
||||
|
||||
### Performance Tuning
|
||||
|
||||
| Variable | Default | Description |
|
||||
|
||||
@@ -43,7 +43,7 @@ async fn run_interactive() {
|
||||
|
||||
let main_options = vec![
|
||||
"Reset Admin Password",
|
||||
"Migrate Legacy v0.3.7 Storage to v1.0.x",
|
||||
"Migrate Legacy v0.3.7 Storage to v1.x",
|
||||
"Exit",
|
||||
];
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ use indicatif::{ProgressBar, ProgressStyle};
|
||||
pub fn handle_migration(theme: &ColorfulTheme) {
|
||||
println!(
|
||||
"\n{}",
|
||||
style("MIGRATION: Bichon v0.3.7 Storage Architecture → v1.0.x")
|
||||
style("MIGRATION: Bichon v0.3.7 Storage Architecture → v1.x")
|
||||
.bold()
|
||||
.yellow()
|
||||
);
|
||||
@@ -20,7 +20,7 @@ pub fn handle_migration(theme: &ColorfulTheme) {
|
||||
"{}",
|
||||
style(
|
||||
"This tool migrates data from the legacy v0.3.7 Tantivy-based storage \
|
||||
architecture to the new v1.0.x \
|
||||
architecture to the new v1.x \
|
||||
separated index and Fjall-backed storage format."
|
||||
)
|
||||
.dim()
|
||||
@@ -32,7 +32,7 @@ pub fn handle_migration(theme: &ColorfulTheme) {
|
||||
"Legacy v0.3.7 architecture:\n\
|
||||
• envelope metadata stored in Tantivy\n\
|
||||
• message data stored in Tantivy\n\n\
|
||||
New v1.0.x architecture:\n\
|
||||
New v1.x architecture:\n\
|
||||
• mail indexes stored in Tantivy\n\
|
||||
• attachment indexes stored in Tantivy\n\
|
||||
• raw message data stored in Fjall\n\
|
||||
@@ -163,7 +163,7 @@ pub fn handle_migration(theme: &ColorfulTheme) {
|
||||
println!("----------------------------------------");
|
||||
|
||||
println!(
|
||||
"\n{} Checking legacy v0.x storage layout...",
|
||||
"\n{} Checking legacy v0.3.7 storage layout...",
|
||||
style("⌛").yellow()
|
||||
);
|
||||
|
||||
@@ -172,7 +172,7 @@ pub fn handle_migration(theme: &ColorfulTheme) {
|
||||
println!(
|
||||
"{} {}",
|
||||
style("✔").green(),
|
||||
style("Legacy v0.3.7 Tantivy-based storage detected. Migration to v1.0 is required.")
|
||||
style("Legacy v0.3.7 Tantivy-based storage detected. Migration to v1.x is required.")
|
||||
.yellow()
|
||||
);
|
||||
}
|
||||
@@ -186,7 +186,7 @@ pub fn handle_migration(theme: &ColorfulTheme) {
|
||||
println!(
|
||||
"{}",
|
||||
style(
|
||||
"The selected directories may already be using the v1.0 storage architecture."
|
||||
"The selected directories may already be using the v1.x storage architecture."
|
||||
)
|
||||
.dim()
|
||||
);
|
||||
@@ -288,12 +288,6 @@ pub fn handle_migration(theme: &ColorfulTheme) {
|
||||
style(batch_size).cyan().bold()
|
||||
);
|
||||
|
||||
println!(
|
||||
"{} Using batch size: {}\n",
|
||||
style("✓").green(),
|
||||
style(batch_size).cyan().bold()
|
||||
);
|
||||
|
||||
let legacy = LegacyDirs::new(index_path.clone(), data_path.clone());
|
||||
let total_segments = match count_eml_segments(&legacy) {
|
||||
Ok(n) => n,
|
||||
|
||||
@@ -60,3 +60,75 @@ pub fn determine_folder(labels_raw: &str) -> String {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use mail_parser::{HeaderValue, MessageParser};
|
||||
|
||||
use super::*;
|
||||
|
||||
fn parse_x_gmail_labels(raw_message: &[u8]) -> Option<String> {
|
||||
// MessageParser::new() has an empty header_map so the hardcoded match at
|
||||
// parsers/header.rs:76 treats ALL unknown headers as raw (no RFC 2047
|
||||
// decoding). We need three things to get decoding:
|
||||
// 1. A non-empty header_map (so the else branch runs)
|
||||
// 2. default_header_text() so the fallback fn is parse_unstructured
|
||||
// 3. OR register X-Gmail-Labels explicitly via header_text()
|
||||
let message = MessageParser::new()
|
||||
.with_minimal_headers()
|
||||
.default_header_text()
|
||||
.parse(raw_message)?;
|
||||
let value: &HeaderValue<'_> = message.header("X-Gmail-Labels")?;
|
||||
value.as_text().map(|s| s.to_string())
|
||||
}
|
||||
|
||||
/// Construct a raw MIME message with RFC 2047 encoded X-Gmail-Labels,
|
||||
/// parse it, and verify the header is correctly decoded.
|
||||
fn build_email(x_gmail_labels: &str) -> Vec<u8> {
|
||||
format!(
|
||||
"From: sender@example.com\r\n\
|
||||
To: recipient@example.com\r\n\
|
||||
Subject: Test\r\n\
|
||||
X-Gmail-Labels: {}\r\n\
|
||||
\r\n\
|
||||
Body text here.\r\n",
|
||||
x_gmail_labels
|
||||
)
|
||||
.into_bytes()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rfc2047_encoded_labels_are_decoded() {
|
||||
// Exactly the format the user reported: French Gmail labels
|
||||
let raw = build_email("=?UTF-8?Q?Corbeille?=, =?UTF-8?Q?Messages_archiv=C3=A9s?=");
|
||||
let labels = parse_x_gmail_labels(&raw).expect("failed to parse X-Gmail-Labels");
|
||||
|
||||
// mail-parser decodes RFC 2047 header values during initial parsing.
|
||||
// The decoded text should NOT contain raw =?UTF-8?Q?... sequences.
|
||||
assert!(!labels.contains("=?UTF-8"), "labels still encoded: {labels:?}");
|
||||
assert!(labels.contains("Corbeille"), "missing 'Corbeille': {labels:?}");
|
||||
assert!(
|
||||
labels.contains("archivés"),
|
||||
"missing decoded 'archivés': {labels:?}",
|
||||
);
|
||||
|
||||
// Full pipeline: decoded labels → determine_folder
|
||||
let folder = determine_folder(&labels);
|
||||
assert_eq!(folder, "Corbeille");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plain_ascii_labels_passthrough() {
|
||||
let raw = build_email("Inbox, Important");
|
||||
let labels = parse_x_gmail_labels(&raw).expect("failed to parse X-Gmail-Labels");
|
||||
assert_eq!(labels, "Inbox, Important");
|
||||
assert_eq!(determine_folder(&labels), "Important");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_x_gmail_labels_header() {
|
||||
let raw = b"From: sender@example.com\r\nTo: r@example.com\r\n\r\nBody.\r\n";
|
||||
let message = MessageParser::new().parse(raw.as_slice()).unwrap();
|
||||
assert!(message.header("X-Gmail-Labels").is_none());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,7 +28,6 @@ use bichon_core::envelope::meta::{parse_bichon_metadata, BichonMetadata};
|
||||
use console::style;
|
||||
use dialoguer::{theme::ColorfulTheme, Input};
|
||||
use dialoguer::{Confirm, Select};
|
||||
use mail_parser::parsers::MessageStream;
|
||||
use mail_parser::MessageParser;
|
||||
use reqwest::Client;
|
||||
|
||||
@@ -161,7 +160,11 @@ pub async fn run_import(
|
||||
continue;
|
||||
}
|
||||
|
||||
let message = match MessageParser::new().parse(body) {
|
||||
let message = match MessageParser::new()
|
||||
.with_minimal_headers()
|
||||
.default_header_text()
|
||||
.parse(body)
|
||||
{
|
||||
Some(msg) => msg,
|
||||
None => {
|
||||
eprintln!(
|
||||
@@ -181,15 +184,12 @@ pub async fn run_import(
|
||||
}
|
||||
|
||||
let get_default_folder = || {
|
||||
let gmail_labels = message.header_raw("X-Gmail-Labels").unwrap_or("INBOX");
|
||||
let text_cow = MessageStream::new(gmail_labels.as_bytes())
|
||||
.parse_unstructured()
|
||||
.into_text();
|
||||
let data: &str = match &text_cow {
|
||||
Some(c) => c.as_ref(),
|
||||
None => "INBOX",
|
||||
};
|
||||
determine_folder(data)
|
||||
let labels = message
|
||||
.header("X-Gmail-Labels")
|
||||
.and_then(|h| h.as_text())
|
||||
.map(|s| s.to_string())
|
||||
.unwrap_or_else(|| "INBOX".to_string());
|
||||
determine_folder(&labels)
|
||||
};
|
||||
|
||||
let folder_name = if let Some(ref folder) = target_folder {
|
||||
|
||||
@@ -369,6 +369,10 @@ impl Account {
|
||||
new.folder_limit = Some(folder_limit);
|
||||
}
|
||||
|
||||
if let Some(account_name) = request.account_name {
|
||||
new.account_name = Some(account_name);
|
||||
}
|
||||
|
||||
if let Some(clear_folder_limit) = request.clear_folder_limit {
|
||||
if clear_folder_limit {
|
||||
new.folder_limit = None;
|
||||
|
||||
39
crates/core/src/cache/imap/download/rebuild.rs
vendored
39
crates/core/src/cache/imap/download/rebuild.rs
vendored
@@ -17,24 +17,20 @@
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
use crate::{
|
||||
raise_error,
|
||||
{
|
||||
account::{
|
||||
migration::AccountModel,
|
||||
state::{DownloadState, DownloadStatus, FolderStatus},
|
||||
},
|
||||
cache::{
|
||||
imap::{
|
||||
download::flow::{
|
||||
fetch_and_save_by_date, fetch_and_save_full_mailbox, FetchDirection,
|
||||
},
|
||||
mailbox::MailBox,
|
||||
},
|
||||
SEMAPHORE,
|
||||
},
|
||||
error::{code::ErrorCode, BichonResult},
|
||||
store::tantivy::envelope::ENVELOPE_MANAGER,
|
||||
account::{
|
||||
migration::AccountModel,
|
||||
state::{DownloadState, DownloadStatus, FolderStatus},
|
||||
},
|
||||
cache::{
|
||||
imap::{
|
||||
download::flow::{fetch_and_save_by_date, fetch_and_save_full_mailbox, FetchDirection},
|
||||
mailbox::MailBox,
|
||||
},
|
||||
SEMAPHORE,
|
||||
},
|
||||
error::{code::ErrorCode, BichonResult},
|
||||
raise_error,
|
||||
store::tantivy::{attachment::ATTACHMENT_MANAGER, envelope::ENVELOPE_MANAGER},
|
||||
};
|
||||
|
||||
use tokio_util::sync::CancellationToken;
|
||||
@@ -91,7 +87,7 @@ pub async fn rebuild_cache(
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
match fetch_and_save_full_mailbox(&account, &mailbox, token.clone()).await {
|
||||
Ok(_) => {}
|
||||
Err(err) => {
|
||||
@@ -204,7 +200,9 @@ pub async fn rebuild_mailbox_cache(
|
||||
ENVELOPE_MANAGER
|
||||
.delete_mailbox_envelopes(account.id, vec![local_mailbox.id])
|
||||
.await?;
|
||||
|
||||
ATTACHMENT_MANAGER
|
||||
.delete_mailbox_attachments(account.id, vec![local_mailbox.id])
|
||||
.await?;
|
||||
if remote_mailbox.exists == 0 {
|
||||
info!(
|
||||
"Account {}: Mailbox '{}' has no emails on the remote server. The mailbox is empty, no envelopes to fetch.",
|
||||
@@ -237,6 +235,9 @@ pub async fn rebuild_mailbox_cache_by_date(
|
||||
ENVELOPE_MANAGER
|
||||
.delete_mailbox_envelopes(account.id, vec![local_mailbox_id])
|
||||
.await?;
|
||||
ATTACHMENT_MANAGER
|
||||
.delete_mailbox_attachments(account.id, vec![local_mailbox_id])
|
||||
.await?;
|
||||
if remote.exists == 0 {
|
||||
info!(
|
||||
"Account {}: Mailbox '{}' has no emails on the remote server. The mailbox is empty, no envelopes to fetch.",
|
||||
|
||||
@@ -83,16 +83,11 @@ impl DashboardStats {
|
||||
|
||||
stat.email_count = ENVELOPE_MANAGER.total_emails(&authorized_ids)?;
|
||||
stat.attachment_count = ATTACHMENT_MANAGER.total_attachments(&authorized_ids)?;
|
||||
if has_all_accounts {
|
||||
stat.storage_usage_bytes = get_total_size(&DATA_DIR_MANAGER.storage_dir)
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
|
||||
stat.storage_usage_bytes = get_total_size(&DATA_DIR_MANAGER.storage_dir)
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
|
||||
|
||||
stat.index_usage_bytes = get_total_size(&&DATA_DIR_MANAGER.envelope_dir)
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
|
||||
} else {
|
||||
stat.storage_usage_bytes = 0;
|
||||
stat.index_usage_bytes = 0;
|
||||
}
|
||||
stat.index_usage_bytes = get_total_size(&&DATA_DIR_MANAGER.envelope_dir)
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
|
||||
|
||||
stat.system_version = bichon_version!().to_string();
|
||||
|
||||
|
||||
@@ -16,11 +16,13 @@
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
use crate::cache::imap::mailbox::MailBox;
|
||||
use crate::common::AddrVec;
|
||||
use crate::envelope::meta::parse_bichon_metadata;
|
||||
use crate::envelope::utils::normalize_subject;
|
||||
use crate::error::code::ErrorCode;
|
||||
use crate::error::BichonResult;
|
||||
use crate::imap::executor::ImapExecutor;
|
||||
use crate::message::content::AttachmentInfo;
|
||||
use crate::store::blob::{DetachedEmail, BLOB_MANAGER};
|
||||
use crate::store::tantivy::attachment::ATTACHMENT_MANAGER;
|
||||
@@ -87,6 +89,7 @@ async fn extract_envelope_core(
|
||||
account_id: u64,
|
||||
mailbox_id: u64,
|
||||
) -> BichonResult<()> {
|
||||
//The content hash of the original raw EML
|
||||
let email_content_hash = compute_content_hash(body);
|
||||
let message: Message<'_> = MessageParser::new().parse(body).ok_or_else(|| {
|
||||
raise_error!(
|
||||
@@ -389,8 +392,10 @@ pub async fn detach_and_store_attachments(
|
||||
for (raw_start, raw_end, att) in ranges {
|
||||
// Step 2: Extract raw bytes and store them as standalone documents
|
||||
let raw_bytes = &original_body[raw_start..raw_end];
|
||||
//This is the content hash of the decoded attachment, not the undecoded one.
|
||||
let content_hash = compute_content_hash(att.contents());
|
||||
|
||||
//"The actual content stored in the blob is the raw undecoded data, to avoid the reconstructed EML differing from the original due to decoding and re-encoding.
|
||||
attachments.push((content_hash.clone(), Bytes::copy_from_slice(raw_bytes)));//
|
||||
|
||||
// Step 3: Replace raw attachment content with a hash-based placeholder
|
||||
@@ -515,6 +520,114 @@ pub fn reattach_eml_content(
|
||||
Ok((e.envelope, Bytes::from(restored_eml)))
|
||||
}
|
||||
|
||||
/// Returns the raw EML for an indexed message, self-healing a missing content blob.
|
||||
///
|
||||
/// Behaves like [`reattach_eml_content`], but when the message's content blob is
|
||||
/// absent from the blob store it fetches that single message on demand from the
|
||||
/// IMAP server (`UID FETCH <uid> (BODY.PEEK[])`), persists it for future requests,
|
||||
/// and returns it. If the on-demand fetch itself fails, the original "content not
|
||||
/// found" error from [`reattach_eml_content`] is surfaced unchanged so the caller
|
||||
/// still produces its 404.
|
||||
pub async fn reattach_eml_content_self_healing(
|
||||
account_id: u64,
|
||||
envelope_id: String,
|
||||
) -> BichonResult<(Envelope, Bytes)> {
|
||||
let envelope = ENVELOPE_MANAGER
|
||||
.get_envelope_by_id(account_id, &envelope_id)?
|
||||
.ok_or_else(|| {
|
||||
raise_error!(
|
||||
format!(
|
||||
"Envelope not found: account_id={} envelope_id={}",
|
||||
account_id, &envelope_id
|
||||
),
|
||||
ErrorCode::ResourceNotFound
|
||||
)
|
||||
})?
|
||||
.envelope;
|
||||
|
||||
// Fast path: the content blob is present, reuse the regular reattach logic.
|
||||
if BLOB_MANAGER.get_email(&envelope.content_hash)?.is_some() {
|
||||
return reattach_eml_content(account_id, envelope_id);
|
||||
}
|
||||
|
||||
// The blob is missing. Try to recover it directly from the IMAP server.
|
||||
match recover_message_blob(&envelope).await {
|
||||
Ok(raw_body) => {
|
||||
tracing::info!(
|
||||
account_id,
|
||||
envelope_id = %envelope_id,
|
||||
uid = envelope.uid,
|
||||
"Self-healed missing email content blob via on-demand IMAP fetch"
|
||||
);
|
||||
Ok((envelope, raw_body))
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
account_id,
|
||||
envelope_id = %envelope_id,
|
||||
uid = envelope.uid,
|
||||
error = %e,
|
||||
"On-demand IMAP fetch for missing content blob failed; returning not-found"
|
||||
);
|
||||
Err(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetches one message from IMAP and re-stores its detached blob.
|
||||
///
|
||||
/// On success the freshly fetched raw RFC822 body is returned; it is also queued
|
||||
/// (in detached form) into the blob store so subsequent requests hit the cache.
|
||||
/// Fails if the message cannot be fetched, or if the fetched bytes do not match
|
||||
/// the archived `content_hash` (the server-side message no longer matches what
|
||||
/// Bichon archived, so it cannot be treated as a recovery of that blob).
|
||||
async fn recover_message_blob(envelope: &Envelope) -> BichonResult<Bytes> {
|
||||
let mailbox = MailBox::find_mailbox(envelope.account_id, envelope.mailbox_id)?
|
||||
.ok_or_else(|| {
|
||||
raise_error!(
|
||||
format!(
|
||||
"Mailbox not found: account_id={} mailbox_id={}",
|
||||
envelope.account_id, envelope.mailbox_id
|
||||
),
|
||||
ErrorCode::ResourceNotFound
|
||||
)
|
||||
})?;
|
||||
|
||||
let mut session = ImapExecutor::create_connection(envelope.account_id).await?;
|
||||
let result = ImapExecutor::fetch_single_message_body(
|
||||
&mut session,
|
||||
&mailbox.encoded_name(),
|
||||
envelope.uid,
|
||||
)
|
||||
.await;
|
||||
session.logout().await.ok();
|
||||
let raw_body = result?;
|
||||
|
||||
let fetched_hash = compute_content_hash(&raw_body);
|
||||
if fetched_hash != envelope.content_hash {
|
||||
return Err(raise_error!(
|
||||
format!(
|
||||
"Fetched message does not match archived content: expected content_hash={} got={}",
|
||||
envelope.content_hash, fetched_hash
|
||||
),
|
||||
ErrorCode::ImapUnexpectedResult
|
||||
));
|
||||
}
|
||||
|
||||
// Re-create the detached blob (stripped EML + attachments) so the missing
|
||||
// blob is repopulated for future requests. The detached EML is queued under
|
||||
// `fetched_hash`, which equals `envelope.content_hash`.
|
||||
let message = MessageParser::new().parse(raw_body.as_slice()).ok_or_else(|| {
|
||||
raise_error!(
|
||||
"Failed to parse fetched email content".into(),
|
||||
ErrorCode::InternalError
|
||||
)
|
||||
})?;
|
||||
detach_and_store_attachments(&raw_body, &message, &fetched_hash).await;
|
||||
|
||||
Ok(Bytes::from(raw_body))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use html2text::config;
|
||||
|
||||
@@ -289,6 +289,58 @@ impl ImapExecutor {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Fetches the raw RFC822 body of a single message by UID.
|
||||
///
|
||||
/// Selects (read-only) the given mailbox and issues `UID FETCH <uid> (BODY.PEEK[])`.
|
||||
/// Used for on-demand self-healing when an indexed message's content blob is missing.
|
||||
/// Returns the raw bytes, or an error if the message cannot be retrieved.
|
||||
pub async fn fetch_single_message_body(
|
||||
session: &mut Session<Box<dyn SessionStream>>,
|
||||
encoded_mailbox_name: &str,
|
||||
uid: u32,
|
||||
) -> BichonResult<Vec<u8>> {
|
||||
session
|
||||
.examine(encoded_mailbox_name)
|
||||
.await
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::ImapCommandFailed))?;
|
||||
|
||||
let mut stream = session
|
||||
.uid_fetch(uid.to_string(), BODY_FETCH_COMMAND)
|
||||
.await
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::ImapCommandFailed))?;
|
||||
|
||||
let fetch = stream
|
||||
.try_next()
|
||||
.await
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::ImapCommandFailed))?
|
||||
.ok_or_else(|| {
|
||||
raise_error!(
|
||||
format!("UID {uid} not found on IMAP server"),
|
||||
ErrorCode::ResourceNotFound
|
||||
)
|
||||
})?;
|
||||
|
||||
let body = fetch
|
||||
.body()
|
||||
.ok_or_else(|| {
|
||||
raise_error!(
|
||||
format!("No body returned for UID {uid}"),
|
||||
ErrorCode::ImapUnexpectedResult
|
||||
)
|
||||
})?
|
||||
.to_vec();
|
||||
|
||||
// // Drain any remaining items so the stream is fully consumed before reuse.
|
||||
// while stream
|
||||
// .try_next()
|
||||
// .await
|
||||
// .map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::ImapCommandFailed))?
|
||||
// .is_some()
|
||||
// {}
|
||||
|
||||
Ok(body)
|
||||
}
|
||||
|
||||
pub async fn create_connection(
|
||||
account_id: u64,
|
||||
) -> BichonResult<Session<Box<dyn SessionStream>>> {
|
||||
|
||||
@@ -22,6 +22,7 @@ use crate::envelope::extractor::{extract_envelope_from_nested_message, reattach_
|
||||
use crate::error::code::ErrorCode;
|
||||
use crate::store::envelope::Envelope;
|
||||
use crate::utils::compute_content_hash;
|
||||
use crate::utils::html::block_remote_content;
|
||||
use crate::{error::BichonResult, raise_error};
|
||||
use mail_parser::{MessageParser, MimeHeaders};
|
||||
//use poem_openapi::Object;
|
||||
@@ -142,6 +143,9 @@ pub struct FullMessageContent {
|
||||
pub html: Option<String>,
|
||||
// all Attachments include inline attachments
|
||||
pub attachments: Option<Vec<AttachmentInfo>>,
|
||||
/// True when remote content (http/https URLs) was detected and stripped from html.
|
||||
#[serde(default)]
|
||||
pub has_remote_content: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize)]
|
||||
@@ -155,11 +159,15 @@ pub struct FullNestedMessageContent {
|
||||
pub attachments: Option<Vec<AttachmentInfo>>,
|
||||
/// Metadata for the email envelope.
|
||||
pub envelope: Envelope,
|
||||
/// True when remote content (http/https URLs) was detected and stripped from html.
|
||||
#[serde(default)]
|
||||
pub has_remote_content: bool,
|
||||
}
|
||||
|
||||
pub fn retrieve_email_content(
|
||||
account_id: u64,
|
||||
envelope_id: String,
|
||||
block_remote: bool,
|
||||
) -> BichonResult<FullMessageContent> {
|
||||
AccountModel::check_account_exists(account_id)?;
|
||||
let (envelope, eml) = reattach_eml_content(account_id, envelope_id)?;
|
||||
@@ -223,10 +231,19 @@ pub fn retrieve_email_content(
|
||||
content_id: attachment.content_id().map(Into::into),
|
||||
});
|
||||
}
|
||||
let mut has_remote_content = false;
|
||||
if let Some(ref html_body) = html {
|
||||
let filtered = block_remote_content(html_body);
|
||||
has_remote_content = *html_body != filtered;
|
||||
if block_remote {
|
||||
html = Some(filtered);
|
||||
}
|
||||
}
|
||||
Ok(FullMessageContent {
|
||||
text,
|
||||
html,
|
||||
attachments: Some(attachments),
|
||||
has_remote_content,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -234,6 +251,7 @@ pub fn retrieve_nested_eml_content(
|
||||
account_id: u64,
|
||||
envelope_id: String,
|
||||
content_hash: &str,
|
||||
block_remote: bool,
|
||||
) -> BichonResult<FullNestedMessageContent> {
|
||||
let (_, eml) = reattach_eml_content(account_id, envelope_id)?;
|
||||
let parent_message = MessageParser::default().parse(&eml).ok_or_else(|| {
|
||||
@@ -314,10 +332,19 @@ pub fn retrieve_nested_eml_content(
|
||||
|
||||
let envelope = extract_envelope_from_nested_message(nested_message, account_id)?;
|
||||
|
||||
let mut has_remote_content = false;
|
||||
if let Some(ref html_body) = html {
|
||||
let filtered = block_remote_content(html_body);
|
||||
has_remote_content = *html_body != filtered;
|
||||
if block_remote {
|
||||
html = Some(filtered);
|
||||
}
|
||||
}
|
||||
Ok(FullNestedMessageContent {
|
||||
text,
|
||||
html,
|
||||
attachments: Some(attachments),
|
||||
envelope,
|
||||
has_remote_content,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -26,6 +26,6 @@ pub async fn delete_messages_impl(request: HashMap<u64, Vec<String>>) -> BichonR
|
||||
.delete_envelopes_multi_account(request.clone())
|
||||
.await?;
|
||||
ATTACHMENT_MANAGER
|
||||
.delete_envelopes_multi_account(request)
|
||||
.delete_attachments_multi_account(request)
|
||||
.await
|
||||
}
|
||||
|
||||
@@ -45,6 +45,14 @@ pub struct EmailSearchFilter {
|
||||
pub bcc: Option<String>,
|
||||
pub since: Option<i64>,
|
||||
pub before: Option<i64>,
|
||||
/// Lower bound (inclusive) on the IMAP server INTERNALDATE timestamp.
|
||||
pub internal_date_since: Option<i64>,
|
||||
/// Upper bound (inclusive) on the IMAP server INTERNALDATE timestamp.
|
||||
pub internal_date_before: Option<i64>,
|
||||
/// Lower bound (inclusive) on Bichon's archival (ingest) timestamp.
|
||||
pub ingest_since: Option<i64>,
|
||||
/// Upper bound (inclusive) on Bichon's archival (ingest) timestamp.
|
||||
pub ingest_before: Option<i64>,
|
||||
pub account_ids: Option<HashSet<u64>>,
|
||||
pub mailbox_ids: Option<HashSet<u64>>,
|
||||
pub min_size: Option<u64>,
|
||||
@@ -64,6 +72,14 @@ pub enum SortBy {
|
||||
#[default]
|
||||
DATE,
|
||||
SIZE,
|
||||
/// Sort by the IMAP server INTERNALDATE timestamp.
|
||||
#[serde(rename = "INTERNAL_DATE")]
|
||||
#[cfg_attr(feature = "web-api", oai(rename = "INTERNAL_DATE"))]
|
||||
InternalDate,
|
||||
/// Sort by Bichon's archival (ingest) timestamp.
|
||||
#[serde(rename = "INGEST_AT")]
|
||||
#[cfg_attr(feature = "web-api", oai(rename = "INGEST_AT"))]
|
||||
IngestAt,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Eq, PartialEq, Serialize, Deserialize)]
|
||||
|
||||
@@ -259,6 +259,12 @@ impl NewIndexWriter {
|
||||
.parse(eml_bytes)
|
||||
.ok_or_else(|| raise_error!("failed to parse eml".into(), ErrorCode::InternalError))?;
|
||||
|
||||
if message.parts.is_empty() {
|
||||
return Err(raise_error!(
|
||||
"Malformed or completely empty EML (no parts found)".into(),
|
||||
ErrorCode::InternalError
|
||||
));
|
||||
}
|
||||
// ── text / preview ────────────────────────────────────────────────
|
||||
let text = message
|
||||
.body_text(0)
|
||||
@@ -436,7 +442,7 @@ impl NewIndexWriter {
|
||||
.commit()
|
||||
.map_err(|e| raise_error!(format!("{e:#?}"), ErrorCode::InternalError))?;
|
||||
}
|
||||
println!("tantivy commit elasped: {:#?}", start.elapsed());
|
||||
println!("tantivy commit elapsed: {:#?}", start.elapsed());
|
||||
tracing::info!(count = self.pending, "committed tantivy batch");
|
||||
self.pending = 0;
|
||||
Ok(())
|
||||
@@ -451,16 +457,10 @@ impl NewIndexWriter {
|
||||
("attachment", &mut self.attachment_writer),
|
||||
] {
|
||||
if let Some(writer) = writer_opt.as_mut() {
|
||||
let reader = writer
|
||||
let seg_ids = writer
|
||||
.index()
|
||||
.reader()
|
||||
.searchable_segment_ids()
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
|
||||
let seg_ids: Vec<_> = reader
|
||||
.searcher()
|
||||
.segment_readers()
|
||||
.iter()
|
||||
.map(|r| r.segment_id())
|
||||
.collect();
|
||||
println!("merging {} {} segments...", seg_ids.len(), name);
|
||||
if seg_ids.len() > 1 {
|
||||
let _ = writer.merge(&seg_ids);
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
|
||||
use crate::{
|
||||
common::signal::SIGNAL_MANAGER,
|
||||
envelope::extractor::reattach_eml_content,
|
||||
envelope::extractor::reattach_eml_content_self_healing,
|
||||
error::{code::ErrorCode, BichonResult},
|
||||
settings::dir::DATA_DIR_MANAGER,
|
||||
};
|
||||
@@ -68,7 +68,9 @@ impl BlobManager {
|
||||
}
|
||||
}
|
||||
Err(e) => tracing::error!("Fjall email_ks error: {:?}", e),
|
||||
_ => {}
|
||||
Ok(true) => {
|
||||
tracing::debug!("Email blob already exists (dedup): {}", &email_hash);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(attachments) = eml.attachments {
|
||||
@@ -80,7 +82,9 @@ impl BlobManager {
|
||||
}
|
||||
}
|
||||
Err(e) => tracing::error!("Fjall attach_ks error: {:?}", e),
|
||||
_ => {}
|
||||
Ok(true) => {
|
||||
tracing::debug!("Attachment blob already exists (dedup): {}", &a_hash);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -185,7 +189,9 @@ impl BlobManager {
|
||||
}
|
||||
|
||||
pub async fn queue(&self, email: DetachedEmail) {
|
||||
let _ = self.sender.send(email).await;
|
||||
if let Err(e) = self.sender.send(email).await {
|
||||
tracing::error!("BlobManager channel closed, email lost: {:#?}", e);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_email(&self, content_hash: &str) -> BichonResult<Option<Bytes>> {
|
||||
@@ -226,7 +232,13 @@ impl BlobManager {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_reader(account_id: u64, eid: String) -> BichonResult<Cursor<Bytes>> {
|
||||
let (_, data) = reattach_eml_content(account_id, eid)?;
|
||||
/// Returns a reader over the raw EML for an indexed message.
|
||||
///
|
||||
/// If the message's content blob is missing from the blob store, it is fetched
|
||||
/// on demand from the IMAP server, persisted, and returned (self-healing). The
|
||||
/// underlying "content not found" error is only surfaced if that on-demand
|
||||
/// fetch itself fails.
|
||||
pub async fn get_reader(account_id: u64, eid: String) -> BichonResult<Cursor<Bytes>> {
|
||||
let (_, data) = reattach_eml_content_self_healing(account_id, eid).await?;
|
||||
Ok(Cursor::new(data))
|
||||
}
|
||||
|
||||
@@ -38,8 +38,8 @@ use crate::{
|
||||
store::tantivy::{
|
||||
fatal_commit,
|
||||
fields::{
|
||||
F_ATTACHMENT_CATEGORY, F_ATTACHMENT_CONTENT_TYPE, F_ATTACHMENT_EXT, F_DATE, F_SIZE,
|
||||
F_TAGS,
|
||||
F_ATTACHMENT_CATEGORY, F_ATTACHMENT_CONTENT_TYPE, F_ATTACHMENT_EXT, F_DATE,
|
||||
F_INGEST_AT, F_SIZE, F_TAGS,
|
||||
},
|
||||
model::{extract_senders, AttachmentModel},
|
||||
schema::SchemaTools,
|
||||
@@ -260,7 +260,7 @@ impl IndexManager {
|
||||
IndexRecordOption::Basic,
|
||||
);
|
||||
let envelope_id_query = TermQuery::new(
|
||||
Term::from_field_text(SchemaTools::attachment_fields().f_id, aid),
|
||||
Term::from_field_text(SchemaTools::attachment_fields().f_envelope_id, aid),
|
||||
IndexRecordOption::Basic,
|
||||
);
|
||||
let boolean_query = BooleanQuery::new(vec![
|
||||
@@ -633,7 +633,7 @@ impl IndexManager {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn delete_envelopes_multi_account(
|
||||
pub async fn delete_attachments_multi_account(
|
||||
&self,
|
||||
deletes: HashMap<u64, Vec<String>>,
|
||||
) -> BichonResult<()> {
|
||||
@@ -864,6 +864,30 @@ impl IndexManager {
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
|
||||
attachment_docs = size_docs.into_iter().map(|(_, addr)| addr).collect();
|
||||
}
|
||||
// Attachments carry no IMAP INTERNALDATE; fall back to the
|
||||
// attachment's own date field so the sort remains well defined.
|
||||
SortBy::InternalDate => {
|
||||
let date_docs: Vec<(Option<i64>, DocAddress)> = searcher
|
||||
.search(
|
||||
&query,
|
||||
&TopDocs::with_limit(page_size as usize)
|
||||
.and_offset(offset as usize)
|
||||
.order_by_fast_field(F_DATE, order),
|
||||
)
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
|
||||
attachment_docs = date_docs.into_iter().map(|(_, addr)| addr).collect();
|
||||
}
|
||||
SortBy::IngestAt => {
|
||||
let ingest_at_docs: Vec<(Option<i64>, DocAddress)> = searcher
|
||||
.search(
|
||||
&query,
|
||||
&TopDocs::with_limit(page_size as usize)
|
||||
.and_offset(offset as usize)
|
||||
.order_by_fast_field(F_INGEST_AT, order),
|
||||
)
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
|
||||
attachment_docs = ingest_at_docs.into_iter().map(|(_, addr)| addr).collect();
|
||||
}
|
||||
}
|
||||
|
||||
let mut result = Vec::new();
|
||||
|
||||
@@ -158,10 +158,10 @@ fn dedup_account(
|
||||
) -> BichonResult<u64> {
|
||||
let searcher = email_reader.searcher();
|
||||
let fields = SchemaTools::email_fields();
|
||||
eprintln!(
|
||||
"DEBUG dedup_account: entry account={account_id} f_id_field={:?} f_content_hash_field={:?}",
|
||||
fields.f_id, fields.f_content_hash
|
||||
);
|
||||
// eprintln!(
|
||||
// "DEBUG dedup_account: entry account={account_id} f_id_field={:?} f_content_hash_field={:?}",
|
||||
// fields.f_id, fields.f_content_hash
|
||||
// );
|
||||
let mut map: DedupMap = HashMap::new();
|
||||
|
||||
// ── Phase 1: build the dedup map via FAST column scans ──────────────────
|
||||
@@ -204,7 +204,11 @@ fn dedup_account(
|
||||
let ingest_at = ingest_col.values.get_val(doc_id);
|
||||
|
||||
// Read content_hash from the dictionary-encoded string column
|
||||
let hash_ord = hash_col.ords().values_for_doc(doc_id as u32).next().unwrap_or(0);
|
||||
let hash_ord = hash_col
|
||||
.ords()
|
||||
.values_for_doc(doc_id as u32)
|
||||
.next()
|
||||
.unwrap_or(0);
|
||||
let mut hash_buf = String::new();
|
||||
hash_col
|
||||
.ord_to_str(hash_ord, &mut hash_buf)
|
||||
@@ -212,16 +216,20 @@ fn dedup_account(
|
||||
let content_hash = hash_buf;
|
||||
|
||||
// Read f_id from the dictionary-encoded string column
|
||||
let id_ord = id_col.ords().values_for_doc(doc_id as u32).next().unwrap_or(0);
|
||||
let id_ord = id_col
|
||||
.ords()
|
||||
.values_for_doc(doc_id as u32)
|
||||
.next()
|
||||
.unwrap_or(0);
|
||||
let mut id_buf = String::new();
|
||||
id_col
|
||||
.ord_to_str(id_ord, &mut id_buf)
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
|
||||
let email_id = id_buf;
|
||||
|
||||
eprintln!(
|
||||
"DEBUG dedup_account: account={account_id} doc_id={doc_id} mailbox={mailbox_id} hash={content_hash:?} id={email_id:?} ingest_at={ingest_at}"
|
||||
);
|
||||
// eprintln!(
|
||||
// "DEBUG dedup_account: account={account_id} doc_id={doc_id} mailbox={mailbox_id} hash={content_hash:?} id={email_id:?} ingest_at={ingest_at}"
|
||||
// );
|
||||
|
||||
map.entry((mailbox_id, content_hash))
|
||||
.or_default()
|
||||
@@ -247,7 +255,11 @@ fn dedup_account(
|
||||
// uidvalidity, which is required for correct incremental sync.
|
||||
entries.sort_by_key(|e| std::cmp::Reverse(e.ingest_at));
|
||||
|
||||
eprintln!("DEBUG Phase2: key={_key:?} kept={} deleting={}", entries[0].email_id, entries.len() - 1);
|
||||
eprintln!(
|
||||
"DEBUG Phase2: key={_key:?} kept={} deleting={}",
|
||||
entries[0].email_id,
|
||||
entries.len() - 1
|
||||
);
|
||||
// Keep entries[0], soft-delete everything else via term query on f_id
|
||||
for entry in &entries[1..] {
|
||||
eprintln!(
|
||||
@@ -315,13 +327,14 @@ mod tests {
|
||||
|
||||
/// Collect non-deleted f_id values from the email index.
|
||||
fn surviving_email_ids(reader: &IndexReader) -> HashSet<String> {
|
||||
reader
|
||||
.reload()
|
||||
.expect("reader reload failed");
|
||||
reader.reload().expect("reader reload failed");
|
||||
let searcher = reader.searcher();
|
||||
let mut ids = HashSet::new();
|
||||
let segments = searcher.segment_readers();
|
||||
eprintln!("DEBUG surviving_email_ids: segment_count={}", segments.len());
|
||||
eprintln!(
|
||||
"DEBUG surviving_email_ids: segment_count={}",
|
||||
segments.len()
|
||||
);
|
||||
for (seg_idx, seg) in segments.iter().enumerate() {
|
||||
let id_col = seg
|
||||
.fast_fields()
|
||||
@@ -332,7 +345,11 @@ mod tests {
|
||||
eprintln!("DEBUG surviving_email_ids: seg={seg_idx} max_doc={max_doc}");
|
||||
for doc_id in 0..max_doc {
|
||||
let is_del = seg.is_deleted(doc_id);
|
||||
let ord = id_col.ords().values_for_doc(doc_id as u32).next().unwrap_or(0);
|
||||
let ord = id_col
|
||||
.ords()
|
||||
.values_for_doc(doc_id as u32)
|
||||
.next()
|
||||
.unwrap_or(0);
|
||||
let mut buf = String::new();
|
||||
id_col.ord_to_str(ord, &mut buf).unwrap();
|
||||
eprintln!("DEBUG surviving_email_ids: seg={seg_idx} doc_id={doc_id} is_deleted={is_del} ord={ord} buf={buf:?}");
|
||||
@@ -359,7 +376,11 @@ mod tests {
|
||||
if seg.is_deleted(doc_id) {
|
||||
continue;
|
||||
}
|
||||
let ord = id_col.ords().values_for_doc(doc_id as u32).next().unwrap_or(0);
|
||||
let ord = id_col
|
||||
.ords()
|
||||
.values_for_doc(doc_id as u32)
|
||||
.next()
|
||||
.unwrap_or(0);
|
||||
let mut buf = String::new();
|
||||
id_col.ord_to_str(ord, &mut buf).unwrap();
|
||||
ids.insert(buf);
|
||||
@@ -454,15 +475,17 @@ mod tests {
|
||||
|
||||
let email_r = email_idx.reader().unwrap();
|
||||
let survivors = surviving_email_ids(&email_r);
|
||||
let expected: HashSet<String> =
|
||||
expected_emails.iter().map(|s| s.to_string()).collect();
|
||||
let expected: HashSet<String> = expected_emails.iter().map(|s| s.to_string()).collect();
|
||||
assert_eq!(survivors, expected, "[{case}] email survivors mismatch");
|
||||
|
||||
let attach_r = attach_idx.reader().unwrap();
|
||||
let att_survivors = surviving_attachment_ids(&attach_r);
|
||||
let att_expected: HashSet<String> =
|
||||
expected_attachments.iter().map(|s| s.to_string()).collect();
|
||||
assert_eq!(att_survivors, att_expected, "[{case}] attachment survivors mismatch");
|
||||
assert_eq!(
|
||||
att_survivors, att_expected,
|
||||
"[{case}] attachment survivors mismatch"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -494,7 +517,7 @@ mod tests {
|
||||
add_attachment(af, aw, &format!("att-{i}"), &id, 1, 1);
|
||||
}
|
||||
},
|
||||
&["dup-2"], // ingest_at=400, the latest
|
||||
&["dup-2"], // ingest_at=400, the latest
|
||||
&["att-2"],
|
||||
)
|
||||
.await;
|
||||
@@ -591,7 +614,7 @@ mod tests {
|
||||
/// This test is read-only — it does not modify the index.
|
||||
#[test]
|
||||
fn inspect_production_duplicates() {
|
||||
let index_path = r"E:\db\data\bichon-indices\mail_metadata";
|
||||
let index_path = r"E:\bichon-data\bichon-indices\mail_metadata";
|
||||
let report_path = std::path::PathBuf::from(r"E:\bichon\dedup_report.txt");
|
||||
|
||||
let mut report = String::new();
|
||||
@@ -622,26 +645,21 @@ mod tests {
|
||||
let searcher = reader.searcher();
|
||||
|
||||
let mut total_docs = 0u64;
|
||||
let mut groups: std::collections::HashMap<u64, std::collections::HashMap<(u64, String), u64>> =
|
||||
std::collections::HashMap::new();
|
||||
let mut groups: std::collections::HashMap<
|
||||
u64,
|
||||
std::collections::HashMap<(u64, String), u64>,
|
||||
> = std::collections::HashMap::new();
|
||||
|
||||
for segment_reader in searcher.segment_readers() {
|
||||
let account_col = segment_reader
|
||||
.fast_fields()
|
||||
.u64(F_ACCOUNT_ID)
|
||||
.unwrap();
|
||||
let mailbox_col = segment_reader
|
||||
.fast_fields()
|
||||
.u64(F_MAILBOX_ID)
|
||||
.unwrap();
|
||||
let hash_col = match segment_reader
|
||||
.fast_fields()
|
||||
.str(F_CONTENT_HASH)
|
||||
.unwrap()
|
||||
{
|
||||
let account_col = segment_reader.fast_fields().u64(F_ACCOUNT_ID).unwrap();
|
||||
let mailbox_col = segment_reader.fast_fields().u64(F_MAILBOX_ID).unwrap();
|
||||
let hash_col = match segment_reader.fast_fields().str(F_CONTENT_HASH).unwrap() {
|
||||
Some(c) => c,
|
||||
None => {
|
||||
let _ = writeln!(report, "Segment has no FAST str column for content_hash, skipping");
|
||||
let _ = writeln!(
|
||||
report,
|
||||
"Segment has no FAST str column for content_hash, skipping"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
@@ -655,7 +673,11 @@ mod tests {
|
||||
let account_id = account_col.values.get_val(doc_id);
|
||||
let mailbox_id = mailbox_col.values.get_val(doc_id);
|
||||
|
||||
let hash_ord = hash_col.ords().values_for_doc(doc_id as u32).next().unwrap_or(0);
|
||||
let hash_ord = hash_col
|
||||
.ords()
|
||||
.values_for_doc(doc_id as u32)
|
||||
.next()
|
||||
.unwrap_or(0);
|
||||
let mut hash_buf = String::new();
|
||||
hash_col.ord_to_str(hash_ord, &mut hash_buf).unwrap();
|
||||
let content_hash = hash_buf;
|
||||
|
||||
@@ -39,10 +39,11 @@ use crate::{
|
||||
blob::BLOB_MANAGER,
|
||||
envelope::Envelope,
|
||||
tantivy::{
|
||||
attachment::ATTACHMENT_MANAGER,
|
||||
fatal_commit,
|
||||
fields::{
|
||||
F_ACCOUNT_ID, F_DATE, F_FROM, F_ID, F_REGULAR_ATTACHMENT_COUNT, F_SIZE, F_TAGS,
|
||||
F_THREAD_ID, F_UID,
|
||||
F_ACCOUNT_ID, F_DATE, F_FROM, F_ID, F_INGEST_AT, F_INTERNAL_DATE,
|
||||
F_REGULAR_ATTACHMENT_COUNT, F_SIZE, F_TAGS, F_THREAD_ID, F_UID,
|
||||
},
|
||||
model::{extract_contacts, EnvelopeWithAttachments},
|
||||
schema::SchemaTools,
|
||||
@@ -213,7 +214,9 @@ impl IndexManager {
|
||||
}
|
||||
|
||||
pub async fn queue(&self, doc: TantivyDocument) {
|
||||
let _ = self.sender.send(doc).await;
|
||||
if let Err(e) = self.sender.send(doc).await {
|
||||
tracing::warn!(error = %e, "Failed to queue document into Tantivy writer channel");
|
||||
}
|
||||
}
|
||||
|
||||
fn open_or_create_index(index_dir: &PathBuf) -> Index {
|
||||
@@ -456,6 +459,40 @@ impl IndexManager {
|
||||
subqueries.push((Occur::Must, Box::new(q)));
|
||||
}
|
||||
|
||||
let start_bound = if let Some(from) = filter.internal_date_since {
|
||||
Bound::Included(Term::from_field_i64(f.f_internal_date, from))
|
||||
} else {
|
||||
Bound::Unbounded
|
||||
};
|
||||
|
||||
let end_bound = if let Some(to) = filter.internal_date_before {
|
||||
Bound::Included(Term::from_field_i64(f.f_internal_date, to))
|
||||
} else {
|
||||
Bound::Unbounded
|
||||
};
|
||||
|
||||
if start_bound != Bound::Unbounded || end_bound != Bound::Unbounded {
|
||||
let q = RangeQuery::new(start_bound, end_bound);
|
||||
subqueries.push((Occur::Must, Box::new(q)));
|
||||
}
|
||||
|
||||
let start_bound = if let Some(from) = filter.ingest_since {
|
||||
Bound::Included(Term::from_field_i64(f.f_ingest_at, from))
|
||||
} else {
|
||||
Bound::Unbounded
|
||||
};
|
||||
|
||||
let end_bound = if let Some(to) = filter.ingest_before {
|
||||
Bound::Included(Term::from_field_i64(f.f_ingest_at, to))
|
||||
} else {
|
||||
Bound::Unbounded
|
||||
};
|
||||
|
||||
if start_bound != Bound::Unbounded || end_bound != Bound::Unbounded {
|
||||
let q = RangeQuery::new(start_bound, end_bound);
|
||||
subqueries.push((Occur::Must, Box::new(q)));
|
||||
}
|
||||
|
||||
if let Some(account_ids) = filter.account_ids {
|
||||
let mut should_queries: Vec<(Occur, Box<dyn Query>)> = Vec::new();
|
||||
for id in account_ids {
|
||||
@@ -727,8 +764,16 @@ impl IndexManager {
|
||||
.commit()
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
|
||||
|
||||
ATTACHMENT_MANAGER
|
||||
.delete_account_attachments(account_id)
|
||||
.await?;
|
||||
|
||||
if !eml_content_hashes.is_empty() || !attachments_content_hashes.is_empty() {
|
||||
self.cleanup_unused_content(eml_content_hashes, attachments_content_hashes)?;
|
||||
self.cleanup_unused_content(
|
||||
&mut writer,
|
||||
eml_content_hashes,
|
||||
attachments_content_hashes,
|
||||
)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -767,7 +812,11 @@ impl IndexManager {
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
|
||||
|
||||
if !eml_content_hashes.is_empty() || !attachments_content_hashes.is_empty() {
|
||||
self.cleanup_unused_content(eml_content_hashes, attachments_content_hashes)?;
|
||||
self.cleanup_unused_content(
|
||||
&mut writer,
|
||||
eml_content_hashes,
|
||||
attachments_content_hashes,
|
||||
)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -812,9 +861,18 @@ impl IndexManager {
|
||||
|
||||
fn cleanup_unused_content(
|
||||
&self,
|
||||
writer: &mut IndexWriter,
|
||||
eml_content_hashes: HashSet<String>,
|
||||
attachments_content_hashes: HashSet<String>,
|
||||
) -> BichonResult<()> {
|
||||
// Reference-count barrier: commit the writer and reload the reader so the
|
||||
// `Count` below is evaluated against a fully committed, freshly-reloaded
|
||||
// index state. Without this, an envelope that shares a content hash but
|
||||
// is still sitting uncommitted in the writer buffer (e.g. added by the
|
||||
// background ingest task before this delete acquired the writer lock)
|
||||
// would be invisible to the searcher, the count would read 0, and a
|
||||
// still-referenced blob would be deleted.
|
||||
fatal_commit(writer);
|
||||
let searcher = self.create_searcher()?;
|
||||
let fields = SchemaTools::email_fields();
|
||||
let mut eml: HashSet<String> = HashSet::new();
|
||||
@@ -831,11 +889,10 @@ impl IndexManager {
|
||||
eml.insert(content_hash);
|
||||
}
|
||||
}
|
||||
|
||||
let mut attachments: HashSet<String> = HashSet::new();
|
||||
for content_hash in attachments_content_hashes {
|
||||
// Check if any other emails still reference this content hash
|
||||
let hash_term = Term::from_field_text(fields.f_content_hash, &content_hash);
|
||||
let hash_term = Term::from_field_text(fields.f_attachment_content_hash, &content_hash);
|
||||
let hash_query = TermQuery::new(hash_term, IndexRecordOption::Basic);
|
||||
let count = searcher
|
||||
.search(&hash_query, &Count)
|
||||
@@ -895,7 +952,11 @@ impl IndexManager {
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
|
||||
|
||||
if !eml_content_hashes.is_empty() || !attachments_content_hashes.is_empty() {
|
||||
self.cleanup_unused_content(eml_content_hashes, attachments_content_hashes)?;
|
||||
self.cleanup_unused_content(
|
||||
&mut writer,
|
||||
eml_content_hashes,
|
||||
attachments_content_hashes,
|
||||
)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
@@ -1136,6 +1197,31 @@ impl IndexManager {
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
|
||||
mailbox_docs = size_docs.into_iter().map(|(_, addr)| addr).collect();
|
||||
}
|
||||
SortBy::InternalDate => {
|
||||
let internal_date_docs: Vec<(Option<i64>, DocAddress)> = searcher
|
||||
.search(
|
||||
&query,
|
||||
&TopDocs::with_limit(page_size as usize)
|
||||
.and_offset(offset as usize)
|
||||
.order_by_fast_field(F_INTERNAL_DATE, order),
|
||||
)
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
|
||||
mailbox_docs = internal_date_docs
|
||||
.into_iter()
|
||||
.map(|(_, addr)| addr)
|
||||
.collect();
|
||||
}
|
||||
SortBy::IngestAt => {
|
||||
let ingest_at_docs: Vec<(Option<i64>, DocAddress)> = searcher
|
||||
.search(
|
||||
&query,
|
||||
&TopDocs::with_limit(page_size as usize)
|
||||
.and_offset(offset as usize)
|
||||
.order_by_fast_field(F_INGEST_AT, order),
|
||||
)
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
|
||||
mailbox_docs = ingest_at_docs.into_iter().map(|(_, addr)| addr).collect();
|
||||
}
|
||||
}
|
||||
|
||||
let mut result = Vec::new();
|
||||
|
||||
@@ -26,10 +26,10 @@ use std::sync::LazyLock;
|
||||
|
||||
use crate::error::code::ErrorCode;
|
||||
use crate::error::BichonResult;
|
||||
use crate::settings::cli::SETTINGS;
|
||||
use crate::raise_error;
|
||||
use crate::settings::cli::SETTINGS;
|
||||
|
||||
static ENCRYPT_PASSWORD: LazyLock<String> = LazyLock::new(|| {
|
||||
pub static ENCRYPT_PASSWORD: LazyLock<String> = LazyLock::new(|| {
|
||||
if let Some(file_path) = &SETTINGS.bichon_encrypt_password_file {
|
||||
return fs::read_to_string(file_path)
|
||||
.expect("failed to read the file with the encrypt password")
|
||||
@@ -102,7 +102,10 @@ pub fn internal_encrypt_string(
|
||||
Ok(general_purpose::URL_SAFE.encode(&result))
|
||||
}
|
||||
|
||||
pub fn internal_decrypt_string(password: &str, data: &str) -> Result<String, ring::error::Unspecified> {
|
||||
pub fn internal_decrypt_string(
|
||||
password: &str,
|
||||
data: &str,
|
||||
) -> Result<String, ring::error::Unspecified> {
|
||||
let data = general_purpose::URL_SAFE
|
||||
.decode(data)
|
||||
.map_err(|_| ring::error::Unspecified)?;
|
||||
@@ -146,8 +149,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_wrong_password_fails() {
|
||||
let encrypted =
|
||||
internal_encrypt_string("correct_password", "secret").unwrap();
|
||||
let encrypted = internal_encrypt_string("correct_password", "secret").unwrap();
|
||||
assert!(internal_decrypt_string("wrong_password", &encrypted).is_err());
|
||||
}
|
||||
|
||||
|
||||
@@ -17,9 +17,63 @@
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
use regex::Regex;
|
||||
use std::panic;
|
||||
use std::sync::LazyLock;
|
||||
use tracing::error;
|
||||
|
||||
/// Removes remote content references from HTML email body.
|
||||
///
|
||||
/// Strips attributes that load content from http:// or https:// URLs,
|
||||
/// keeping data: URIs and cid: references intact. Does NOT affect
|
||||
/// navigation links (<a href>).
|
||||
pub fn block_remote_content(html: &str) -> String {
|
||||
let mut result = html.to_string();
|
||||
|
||||
// 1. Strip src, poster, data attributes with remote URLs.
|
||||
// These always load content regardless of the tag.
|
||||
static SRC_ATTR_RE: LazyLock<Regex> = LazyLock::new(|| {
|
||||
Regex::new(r#"(?i)\s+(src|poster|data)\s*=\s*["'][^"']*(?:https?://|//)[^"']*["']"#).unwrap()
|
||||
});
|
||||
result = SRC_ATTR_RE.replace_all(&result, "").to_string();
|
||||
|
||||
// 2. Strip srcset attributes with remote URLs.
|
||||
static SRCSET_ATTR_RE: LazyLock<Regex> = LazyLock::new(|| {
|
||||
Regex::new(r#"(?i)\s+srcset\s*=\s*["'][^"']*(?:https?://|//)[^"']*["']"#).unwrap()
|
||||
});
|
||||
result = SRCSET_ATTR_RE.replace_all(&result, "").to_string();
|
||||
|
||||
// 3. Strip href on <link> tags (stylesheets), never <a> links.
|
||||
static LINK_HREF_RE: LazyLock<Regex> = LazyLock::new(|| {
|
||||
Regex::new(r#"(?i)(<link\b[^>]*)\s+href\s*=\s*["'][^"']*(?:https?://|//)[^"']*["']"#).unwrap()
|
||||
});
|
||||
result = LINK_HREF_RE.replace_all(&result, "$1").to_string();
|
||||
|
||||
// 4. Strip CSS url() references with remote URLs in inline styles.
|
||||
static CSS_URL_RE: LazyLock<Regex> = LazyLock::new(|| {
|
||||
Regex::new(r#"(?i)url\(\s*["']?\s*(?:https?://|//)[^)"'\s]*\s*["']?\s*\)"#).unwrap()
|
||||
});
|
||||
result = CSS_URL_RE.replace_all(&result, "").to_string();
|
||||
|
||||
// 5. Strip @import url(...) with remote URLs inside <style> blocks.
|
||||
static IMPORT_URL_RE: LazyLock<Regex> = LazyLock::new(|| {
|
||||
Regex::new(
|
||||
r#"(?i)@import\s+url\(\s*["']?\s*(?:https?://|//)[^)"'\s]*\s*["']?\s*\)\s*;"#,
|
||||
)
|
||||
.unwrap()
|
||||
});
|
||||
result = IMPORT_URL_RE.replace_all(&result, "").to_string();
|
||||
|
||||
// 6. Strip background attribute on <body> with remote URLs.
|
||||
static BODY_BG_RE: LazyLock<Regex> = LazyLock::new(|| {
|
||||
Regex::new(r#"(?i)(<body\b[^>]*)\s+background\s*=\s*["'][^"']*(?:https?://|//)[^"']*["']"#)
|
||||
.unwrap()
|
||||
});
|
||||
result = BODY_BG_RE.replace_all(&result, "$1").to_string();
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
pub fn extract_text(html: String) -> String {
|
||||
let result = panic::catch_unwind(|| {
|
||||
html2text::config::plain()
|
||||
@@ -88,4 +142,132 @@ mod tests {
|
||||
let text = extract_text(html);
|
||||
assert!(text.contains("Click here"));
|
||||
}
|
||||
|
||||
mod block_remote {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn strips_img_src_http() {
|
||||
let html = r#"<img src="https://tracker.example.com/pixel.gif" alt="x">"#;
|
||||
let result = block_remote_content(html);
|
||||
assert!(!result.contains("https://tracker.example.com"));
|
||||
assert!(result.contains("alt=")); // other attrs preserved
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strips_img_src_protocol_relative() {
|
||||
let html = r#"<img src="//tracker.example.com/pixel.gif">"#;
|
||||
let result = block_remote_content(html);
|
||||
assert!(!result.contains("//tracker.example.com"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preserves_data_uri() {
|
||||
let html = r#"<img src="data:image/png;base64,ABC123" alt="embedded">"#;
|
||||
let result = block_remote_content(html);
|
||||
assert!(result.contains("data:image/png;base64,ABC123"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preserves_cid_reference() {
|
||||
let html = r#"<img src="cid:abc123@example.com" alt="inline">"#;
|
||||
let result = block_remote_content(html);
|
||||
assert!(result.contains("cid:abc123@example.com"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preserves_anchor_href() {
|
||||
let html = r#"<a href="https://example.com/page">Click</a>"#;
|
||||
let result = block_remote_content(html);
|
||||
assert!(result.contains(r#"href="https://example.com/page""#));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strips_link_stylesheet_href() {
|
||||
let html =
|
||||
r#"<link rel="stylesheet" href="https://fonts.example.com/font.css">"#;
|
||||
let result = block_remote_content(html);
|
||||
assert!(!result.contains("https://fonts.example.com"));
|
||||
assert!(result.contains("<link")); // tag preserved
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strips_script_src() {
|
||||
let html = r#"<script src="https://evil.example.com/malware.js"></script>"#;
|
||||
let result = block_remote_content(html);
|
||||
assert!(!result.contains("https://evil.example.com"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strips_iframe_src() {
|
||||
let html = r#"<iframe src="https://ads.example.com/banner"></iframe>"#;
|
||||
let result = block_remote_content(html);
|
||||
assert!(!result.contains("https://ads.example.com"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strips_css_url_in_style() {
|
||||
let html = r#"<div style="background: url(https://tracker.example.com/bg.jpg)"></div>"#;
|
||||
let result = block_remote_content(html);
|
||||
assert!(!result.contains("https://tracker.example.com"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strips_css_url_protocol_relative() {
|
||||
let html = r#"<div style="background: url(//tracker.example.com/bg.jpg)"></div>"#;
|
||||
let result = block_remote_content(html);
|
||||
assert!(!result.contains("//tracker.example.com"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strips_css_import() {
|
||||
let html =
|
||||
r#"<style>@import url("https://fonts.example.com/font.css");</style>"#;
|
||||
let result = block_remote_content(html);
|
||||
assert!(!result.contains("https://fonts.example.com"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strips_video_poster() {
|
||||
let html = r#"<video poster="https://cdn.example.com/thumb.jpg"></video>"#;
|
||||
let result = block_remote_content(html);
|
||||
assert!(!result.contains("https://cdn.example.com"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strips_srcset() {
|
||||
let html =
|
||||
r#"<img srcset="https://cdn.example.com/img1.jpg 1x, https://cdn.example.com/img2.jpg 2x">"#;
|
||||
let result = block_remote_content(html);
|
||||
assert!(!result.contains("https://cdn.example.com"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strips_body_background() {
|
||||
let html = r#"<body background="https://tracker.example.com/bg.jpg">"#;
|
||||
let result = block_remote_content(html);
|
||||
assert!(!result.contains("https://tracker.example.com"));
|
||||
assert!(result.contains("<body"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handles_mixed_content() {
|
||||
let html = r#"
|
||||
<html>
|
||||
<body>
|
||||
<img src="https://spy.example.com/pixel.gif" width="1" height="1">
|
||||
<img src="data:image/png;base64,OK123" alt="ok">
|
||||
<a href="https://example.com/read-more">Read more</a>
|
||||
<div style="background: url(https://tracker.example.com/bg.jpg) no-repeat"></div>
|
||||
</body>
|
||||
</html>"#;
|
||||
let result = block_remote_content(html);
|
||||
// Remote content gone
|
||||
assert!(!result.contains("spy.example.com"));
|
||||
assert!(!result.contains("tracker.example.com"));
|
||||
// Safe content preserved
|
||||
assert!(result.contains("data:image/png;base64,OK123"));
|
||||
assert!(result.contains(r#"href="https://example.com/read-more""#));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,8 +73,9 @@ async fn main() -> BichonResult<()> {
|
||||
Ok(false) => {
|
||||
error!("Incompatible data format detected.");
|
||||
error!("Your data was created by an older version of Bichon and must be migrated before use.");
|
||||
error!("Please stop the Bichon v0.3.7 service before migration.");
|
||||
error!("Please run: bichon-admin");
|
||||
error!("Documentation: https://github.com/rustmailer/bichon/wiki/migration");
|
||||
error!("Documentation: https://github.com/rustmailer/bichon/wiki/Bichon-Data-Migration:-v0.3.7-%E2%86%92-v1.0");
|
||||
return Err(raise_error!(
|
||||
"Legacy data layout detected".into(),
|
||||
ErrorCode::InternalError
|
||||
@@ -202,7 +203,10 @@ mod api_tests {
|
||||
.map(|v| v.object().get("name").string())
|
||||
.collect();
|
||||
|
||||
assert!(tag_names.contains(&"AccessToken"), "missing AccessToken tag");
|
||||
assert!(
|
||||
tag_names.contains(&"AccessToken"),
|
||||
"missing AccessToken tag"
|
||||
);
|
||||
assert!(tag_names.contains(&"Attachment"), "missing Attachment tag");
|
||||
assert!(tag_names.contains(&"AutoConfig"), "missing AutoConfig tag");
|
||||
assert!(tag_names.contains(&"Account"), "missing Account tag");
|
||||
|
||||
@@ -121,6 +121,8 @@ impl MessageApi {
|
||||
}
|
||||
|
||||
/// Fetches the content of a specific email.
|
||||
/// Set `block_remote_content=true` to strip external images, scripts,
|
||||
/// and other content loaded from http/https URLs.
|
||||
#[oai(
|
||||
path = "/message-content/:account_id/:envelope_id",
|
||||
method = "get",
|
||||
@@ -132,11 +134,18 @@ impl MessageApi {
|
||||
account_id: Path<u64>,
|
||||
/// The ID of the message to fetch.
|
||||
envelope_id: Path<String>,
|
||||
/// Block remote content (http/https URLs) from email body.
|
||||
block_remote_content: Query<Option<bool>>,
|
||||
context: WrappedContext,
|
||||
) -> ApiResult<Json<FullMessageContent>> {
|
||||
let account_id = account_id.0;
|
||||
let block_remote = block_remote_content.0.unwrap_or(false);
|
||||
context.require_permission(Some(account_id), Permission::DATA_READ)?;
|
||||
Ok(Json(retrieve_email_content(account_id, envelope_id.0)?))
|
||||
Ok(Json(retrieve_email_content(
|
||||
account_id,
|
||||
envelope_id.0,
|
||||
block_remote,
|
||||
)?))
|
||||
}
|
||||
|
||||
/// Retrieves the content of an email embedded as an attachment.
|
||||
@@ -152,15 +161,18 @@ impl MessageApi {
|
||||
/// The ID of the message to fetch.
|
||||
envelope_id: Path<String>,
|
||||
content_hash: Query<String>,
|
||||
block_remote_content: Query<Option<bool>>,
|
||||
context: WrappedContext,
|
||||
) -> ApiResult<Json<FullNestedMessageContent>> {
|
||||
let account_id = account_id.0;
|
||||
let block_remote = block_remote_content.0.unwrap_or(false);
|
||||
context.require_permission(Some(account_id), Permission::DATA_READ)?;
|
||||
let content_hash = content_hash.0.trim();
|
||||
Ok(Json(retrieve_nested_eml_content(
|
||||
account_id,
|
||||
envelope_id.0,
|
||||
content_hash,
|
||||
block_remote,
|
||||
)?))
|
||||
}
|
||||
|
||||
@@ -213,7 +225,7 @@ impl MessageApi {
|
||||
AccountModel::check_account_exists(account_id)?;
|
||||
context.require_permission(Some(account_id), Permission::DATA_RAW_DOWNLOAD)?;
|
||||
let envelope_id = envelope_id.0;
|
||||
let reader = get_reader(account_id, envelope_id.clone())?;
|
||||
let reader = get_reader(account_id, envelope_id.clone()).await?;
|
||||
let body = Body::from_async_read(reader);
|
||||
let attachment = Attachment::new(body)
|
||||
.attachment_type(AttachmentType::Attachment)
|
||||
|
||||
@@ -10,7 +10,10 @@
|
||||
"preview": "vite preview",
|
||||
"format:check": "prettier --check .",
|
||||
"format": "prettier --write .",
|
||||
"knip": "knip"
|
||||
"knip": "knip",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage"
|
||||
},
|
||||
"dependencies": {
|
||||
"@emotion/react": "^11.14.0",
|
||||
@@ -81,6 +84,9 @@
|
||||
"@tanstack/react-query-devtools": "^5.62.3",
|
||||
"@tanstack/router-devtools": "^1.86.1",
|
||||
"@tanstack/router-plugin": "^1.86.0",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@trivago/prettier-plugin-sort-imports": "^4.3.0",
|
||||
"@types/file-saver": "^2.0.7",
|
||||
"@types/js-cookie": "^3.0.6",
|
||||
@@ -88,18 +94,22 @@
|
||||
"@types/react": "^18.3.18",
|
||||
"@types/react-dom": "^18.3.5",
|
||||
"@vitejs/plugin-react-swc": "^3.7.2",
|
||||
"@vitest/coverage-v8": "^4.1.7",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"eslint": "^9.16.0",
|
||||
"eslint-plugin-react-hooks": "^5.1.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.16",
|
||||
"globals": "^15.13.0",
|
||||
"jsdom": "^29.1.1",
|
||||
"knip": "^5.41.1",
|
||||
"msw": "^2.14.6",
|
||||
"postcss": "^8.4.49",
|
||||
"prettier": "^3.4.2",
|
||||
"prettier-plugin-tailwindcss": "^0.6.9",
|
||||
"tailwindcss": "^3.4.16",
|
||||
"typescript": "~5.7.2",
|
||||
"typescript-eslint": "^8.17.0",
|
||||
"vite": "^6.0.11"
|
||||
"vite": "^6.0.11",
|
||||
"vitest": "^4.1.7"
|
||||
}
|
||||
}
|
||||
1192
web/pnpm-lock.yaml
generated
1192
web/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -64,7 +64,8 @@ export interface AttachmentInfo {
|
||||
export interface MessageContentResponse {
|
||||
text?: string;
|
||||
html?: string;
|
||||
attachments?: AttachmentInfo[]
|
||||
attachments?: AttachmentInfo[];
|
||||
has_remote_content?: boolean;
|
||||
}
|
||||
|
||||
export interface NestedMessageContentResponse {
|
||||
@@ -72,6 +73,7 @@ export interface NestedMessageContentResponse {
|
||||
html?: string;
|
||||
attachments?: AttachmentInfo[];
|
||||
envelope: EmailEnvelope;
|
||||
has_remote_content?: boolean;
|
||||
}
|
||||
|
||||
export const getContent = (messageContent: MessageContentResponse): string | null => {
|
||||
@@ -83,13 +85,25 @@ export const getContent = (messageContent: MessageContentResponse): string | nul
|
||||
return null;
|
||||
};
|
||||
|
||||
export const load_message = async (accountId: number, id: string) => {
|
||||
const response = await axiosInstance.get<MessageContentResponse>(`api/v1/message-content/${accountId}/${id}`);
|
||||
export const load_message = async (accountId: number, id: string, blockRemoteContent = false) => {
|
||||
const params = new URLSearchParams();
|
||||
if (blockRemoteContent) {
|
||||
params.set('block_remote_content', 'true');
|
||||
}
|
||||
const qs = params.toString();
|
||||
const url = `api/v1/message-content/${accountId}/${id}${qs ? '?' + qs : ''}`;
|
||||
const response = await axiosInstance.get<MessageContentResponse>(url);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const load_nested_message = async (accountId: number, id: string, content_hash: string) => {
|
||||
const response = await axiosInstance.get<NestedMessageContentResponse>(`api/v1/nested-message-content/${accountId}/${id}?content_hash=${content_hash}`);
|
||||
export const load_nested_message = async (accountId: number, id: string, content_hash: string, blockRemoteContent = false) => {
|
||||
const params = new URLSearchParams({ content_hash });
|
||||
if (blockRemoteContent) {
|
||||
params.set('block_remote_content', 'true');
|
||||
}
|
||||
const response = await axiosInstance.get<NestedMessageContentResponse>(
|
||||
`api/v1/nested-message-content/${accountId}/${id}?${params.toString()}`
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ const EmailIframe: React.FC<EmailIframeProps> = ({ emailHtml, height }) => {
|
||||
return (
|
||||
<iframe
|
||||
src={iframeSrc}
|
||||
sandbox="allow-scripts"
|
||||
sandbox=""
|
||||
className="w-full border-none"
|
||||
title="Email Content"
|
||||
style={{ height: height ?? '4000px' }}
|
||||
|
||||
@@ -202,7 +202,7 @@ const Sidebar = React.forwardRef<
|
||||
<SheetContent
|
||||
data-sidebar='sidebar'
|
||||
data-mobile='true'
|
||||
className='w-[--sidebar-width] bg-sidebar p-0 text-sidebar-foreground [&>button]:hidden'
|
||||
className='w-[--sidebar-width] !bg-sidebar p-0 text-sidebar-foreground [&>button]:hidden'
|
||||
style={
|
||||
{
|
||||
'--sidebar-width': SIDEBAR_WIDTH_MOBILE,
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { getAccountSchema } from '../schema'
|
||||
|
||||
const t = (key: string) => key
|
||||
|
||||
const baseData = {
|
||||
email: 'test@example.com',
|
||||
imap: {
|
||||
host: 'imap.example.com',
|
||||
port: 993,
|
||||
encryption: 'Ssl' as const,
|
||||
auth: {
|
||||
auth_type: 'Password' as const,
|
||||
password: 'mypassword',
|
||||
},
|
||||
},
|
||||
enabled: true,
|
||||
use_dangerous: false,
|
||||
download_interval_min: 60,
|
||||
download_batch_size: 30,
|
||||
auto_download_new_mailboxes: true,
|
||||
}
|
||||
|
||||
describe('Account Schema - date_since validation', () => {
|
||||
const schema = getAccountSchema(false, t)
|
||||
|
||||
it('accepts fixed date_since', () => {
|
||||
const result = schema.safeParse({
|
||||
...baseData,
|
||||
date_since: { fixed: '2024-01-01' },
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts relative date_since', () => {
|
||||
const result = schema.safeParse({
|
||||
...baseData,
|
||||
date_since: { relative: { unit: 'Months', value: 6 } },
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts undefined date_since', () => {
|
||||
const result = schema.safeParse(baseData)
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects relative date_since with value 0', () => {
|
||||
const result = schema.safeParse({
|
||||
...baseData,
|
||||
date_since: { relative: { unit: 'Months', value: 0 } },
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects relative date_since with negative value', () => {
|
||||
const result = schema.safeParse({
|
||||
...baseData,
|
||||
date_since: { relative: { unit: 'Months', value: -1 } },
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects relative date_since with non-integer value', () => {
|
||||
const result = schema.safeParse({
|
||||
...baseData,
|
||||
date_since: { relative: { unit: 'Months', value: 1.5 } },
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects fixed date_since with empty string', () => {
|
||||
const result = schema.safeParse({
|
||||
...baseData,
|
||||
date_since: { fixed: '' },
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Account Schema - date_before validation', () => {
|
||||
const schema = getAccountSchema(false, t)
|
||||
|
||||
it('accepts valid date_before', () => {
|
||||
const result = schema.safeParse({
|
||||
...baseData,
|
||||
date_before: { unit: 'Days', value: 30 },
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts undefined date_before', () => {
|
||||
const result = schema.safeParse(baseData)
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects date_before with value 0', () => {
|
||||
const result = schema.safeParse({
|
||||
...baseData,
|
||||
date_before: { unit: 'Days', value: 0 },
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Account Schema - use_dangerous and enabled flags', () => {
|
||||
const schema = getAccountSchema(false, t)
|
||||
|
||||
it('accepts use_dangerous: true', () => {
|
||||
const result = schema.safeParse({ ...baseData, use_dangerous: true })
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts enabled: false', () => {
|
||||
const result = schema.safeParse({ ...baseData, enabled: false })
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts auto_download_new_mailboxes: false', () => {
|
||||
const result = schema.safeParse({
|
||||
...baseData,
|
||||
auto_download_new_mailboxes: false,
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Account Schema - missing required nested fields', () => {
|
||||
const schema = getAccountSchema(false, t)
|
||||
|
||||
it('rejects missing imap entirely', () => {
|
||||
const { imap, ...noImap } = baseData
|
||||
const result = schema.safeParse(noImap)
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects missing imap.auth', () => {
|
||||
const { auth, ...noAuth } = baseData.imap
|
||||
const result = schema.safeParse({
|
||||
...baseData,
|
||||
imap: noAuth,
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
})
|
||||
324
web/src/features/accounts/components/__tests__/schema.test.ts
Normal file
324
web/src/features/accounts/components/__tests__/schema.test.ts
Normal file
@@ -0,0 +1,324 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { getAccountSchema, getAuthConfigSchema } from '../schema'
|
||||
|
||||
const t = (key: string) => key
|
||||
|
||||
const validAccountData = {
|
||||
email: 'user@example.com',
|
||||
imap: {
|
||||
host: 'imap.example.com',
|
||||
port: 993,
|
||||
encryption: 'Ssl' as const,
|
||||
auth: {
|
||||
auth_type: 'Password' as const,
|
||||
password: 'mypassword',
|
||||
},
|
||||
},
|
||||
enabled: true,
|
||||
use_dangerous: false,
|
||||
download_interval_min: 60,
|
||||
download_batch_size: 30,
|
||||
auto_download_new_mailboxes: true,
|
||||
}
|
||||
|
||||
describe('Account Form Schema', () => {
|
||||
describe('email field', () => {
|
||||
const schema = getAccountSchema(false, t)
|
||||
|
||||
it('rejects empty email', () => {
|
||||
const result = schema.safeParse({ ...validAccountData, email: '' })
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects invalid email format', () => {
|
||||
const result = schema.safeParse({
|
||||
...validAccountData,
|
||||
email: 'not-an-email',
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects email without @', () => {
|
||||
const result = schema.safeParse({
|
||||
...validAccountData,
|
||||
email: 'username',
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('accepts valid email', () => {
|
||||
const result = schema.safeParse(validAccountData)
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('imap.host field', () => {
|
||||
it('rejects empty IMAP host', () => {
|
||||
const result = getAccountSchema(false, t).safeParse({
|
||||
...validAccountData,
|
||||
imap: { ...validAccountData.imap, host: '' },
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('accepts valid hostname', () => {
|
||||
const result = getAccountSchema(false, t).safeParse(validAccountData)
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts IP address as host', () => {
|
||||
const result = getAccountSchema(false, t).safeParse({
|
||||
...validAccountData,
|
||||
imap: { ...validAccountData.imap, host: '192.168.1.1' },
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('imap.port field', () => {
|
||||
it('accepts port 993 (standard IMAP SSL)', () => {
|
||||
const result = getAccountSchema(false, t).safeParse(validAccountData)
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts port 143 (standard IMAP)', () => {
|
||||
const result = getAccountSchema(false, t).safeParse({
|
||||
...validAccountData,
|
||||
imap: { ...validAccountData.imap, port: 143 },
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts port 0 (auto-detect)', () => {
|
||||
const result = getAccountSchema(false, t).safeParse({
|
||||
...validAccountData,
|
||||
imap: { ...validAccountData.imap, port: 0 },
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects negative port', () => {
|
||||
const result = getAccountSchema(false, t).safeParse({
|
||||
...validAccountData,
|
||||
imap: { ...validAccountData.imap, port: -1 },
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects port > 65535', () => {
|
||||
const result = getAccountSchema(false, t).safeParse({
|
||||
...validAccountData,
|
||||
imap: { ...validAccountData.imap, port: 99999 },
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects non-integer port', () => {
|
||||
const result = getAccountSchema(false, t).safeParse({
|
||||
...validAccountData,
|
||||
imap: { ...validAccountData.imap, port: 993.5 },
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('imap.encryption field', () => {
|
||||
it('accepts Ssl', () => {
|
||||
const result = getAccountSchema(false, t).safeParse(validAccountData)
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts StartTls', () => {
|
||||
const result = getAccountSchema(false, t).safeParse({
|
||||
...validAccountData,
|
||||
imap: { ...validAccountData.imap, encryption: 'StartTls' },
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts None', () => {
|
||||
const result = getAccountSchema(false, t).safeParse({
|
||||
...validAccountData,
|
||||
imap: { ...validAccountData.imap, encryption: 'None' },
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects invalid encryption value', () => {
|
||||
const result = getAccountSchema(false, t).safeParse({
|
||||
...validAccountData,
|
||||
imap: { ...validAccountData.imap, encryption: 'TLS' },
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('download_interval_min field', () => {
|
||||
it('rejects value less than 10', () => {
|
||||
const result = getAccountSchema(false, t).safeParse({
|
||||
...validAccountData,
|
||||
download_interval_min: 5,
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('accepts value of exactly 10', () => {
|
||||
const result = getAccountSchema(false, t).safeParse({
|
||||
...validAccountData,
|
||||
download_interval_min: 10,
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects non-integer value', () => {
|
||||
const result = getAccountSchema(false, t).safeParse({
|
||||
...validAccountData,
|
||||
download_interval_min: 30.5,
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('download_batch_size field', () => {
|
||||
it('rejects value less than 10', () => {
|
||||
const result = getAccountSchema(false, t).safeParse({
|
||||
...validAccountData,
|
||||
download_batch_size: 5,
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects value greater than 200', () => {
|
||||
const result = getAccountSchema(false, t).safeParse({
|
||||
...validAccountData,
|
||||
download_batch_size: 500,
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('accepts value of exactly 10', () => {
|
||||
const result = getAccountSchema(false, t).safeParse({
|
||||
...validAccountData,
|
||||
download_batch_size: 10,
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts value of exactly 200', () => {
|
||||
const result = getAccountSchema(false, t).safeParse({
|
||||
...validAccountData,
|
||||
download_batch_size: 200,
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('folder_limit field', () => {
|
||||
it('accepts undefined folder_limit', () => {
|
||||
const result = getAccountSchema(false, t).safeParse(validAccountData)
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts null folder_limit', () => {
|
||||
const result = getAccountSchema(false, t).safeParse({
|
||||
...validAccountData,
|
||||
folder_limit: null,
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects folder_limit less than 100', () => {
|
||||
const result = getAccountSchema(false, t).safeParse({
|
||||
...validAccountData,
|
||||
folder_limit: 50,
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('accepts folder_limit of exactly 100', () => {
|
||||
const result = getAccountSchema(false, t).safeParse({
|
||||
...validAccountData,
|
||||
folder_limit: 100,
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('account_name and login_name fields', () => {
|
||||
it('accepts undefined account_name and login_name', () => {
|
||||
const result = getAccountSchema(false, t).safeParse(validAccountData)
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts provided account_name', () => {
|
||||
const result = getAccountSchema(false, t).safeParse({
|
||||
...validAccountData,
|
||||
account_name: 'My Work Email',
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts provided login_name', () => {
|
||||
const result = getAccountSchema(false, t).safeParse({
|
||||
...validAccountData,
|
||||
login_name: 'username',
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Auth Config Schema (password validation)', () => {
|
||||
describe('when creating (isEdit = false)', () => {
|
||||
const schema = getAuthConfigSchema(false, t)
|
||||
|
||||
it('requires password when auth_type is Password', () => {
|
||||
const result = schema.safeParse({
|
||||
auth_type: 'Password',
|
||||
password: '',
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('requires password when auth_type is Password and password undefined', () => {
|
||||
const result = schema.safeParse({
|
||||
auth_type: 'Password',
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('accepts valid password with Password auth', () => {
|
||||
const result = schema.safeParse({
|
||||
auth_type: 'Password',
|
||||
password: 'mypassword',
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('does not require password when auth_type is OAuth2', () => {
|
||||
const result = schema.safeParse({
|
||||
auth_type: 'OAuth2',
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('when editing (isEdit = true)', () => {
|
||||
const schema = getAuthConfigSchema(true, t)
|
||||
|
||||
it('does not require password even with Password auth', () => {
|
||||
const result = schema.safeParse({
|
||||
auth_type: 'Password',
|
||||
password: '',
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts with undefined password', () => {
|
||||
const result = schema.safeParse({
|
||||
auth_type: 'Password',
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -19,7 +19,6 @@
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import * as React from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Form } from '@/components/ui/form';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
@@ -35,112 +34,9 @@ import { ToastAction } from '@/components/ui/toast';
|
||||
import { AxiosError } from 'axios';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { cn } from "@/lib/utils";
|
||||
import { getAccountSchema, type AccountFormValues } from './schema';
|
||||
|
||||
const encryptionSchema = z.union([
|
||||
z.literal('Ssl'),
|
||||
z.literal('StartTls'),
|
||||
z.literal('None'),
|
||||
]);
|
||||
|
||||
const authTypeSchema = z.union([
|
||||
z.literal('Password'),
|
||||
z.literal('OAuth2'),
|
||||
]);
|
||||
|
||||
const getAuthConfigSchema = (isEdit: boolean, t: (key: string) => string) =>
|
||||
z.object({
|
||||
auth_type: authTypeSchema,
|
||||
password: z.string().optional(),
|
||||
}).refine(
|
||||
(data) => {
|
||||
if (data.auth_type === 'Password' && !isEdit) {
|
||||
return !!data.password?.trim();
|
||||
}
|
||||
return true;
|
||||
},
|
||||
{
|
||||
message: t('validation.passwordRequired'),
|
||||
path: ['password'],
|
||||
}
|
||||
);
|
||||
|
||||
const getImapConfigSchema = (isEdit: boolean, t: (key: string) => string) =>
|
||||
z.object({
|
||||
host: z.string({ required_error: t('validation.imapHostRequired') }).min(1, { message: t('validation.imapHostCannotBeEmpty') }),
|
||||
port: z.number().int().min(0, { message: t('validation.imapPortMustBePositive') }).max(65535, { message: t('validation.imapPortMustBeLessThan65536') }),
|
||||
encryption: encryptionSchema,
|
||||
auth: getAuthConfigSchema(isEdit, t),
|
||||
use_proxy: z.number().optional(),
|
||||
});
|
||||
|
||||
const getRelativeDateSchema = (t: (key: string) => string) => z.object({
|
||||
unit: z.enum(["Days", "Months", "Years"], { message: t('accounts.selectUnit') }),
|
||||
value: z.number({ message: t('accounts.enterValue') }).int().min(1, t('accounts.mustBeAtLeast1')),
|
||||
});
|
||||
|
||||
const getDateSelectionSchema = (t: (key: string) => string) => z.union([
|
||||
z.object({ fixed: z.string({ message: t('accounts.selectDate') }) }),
|
||||
z.object({ relative: getRelativeDateSchema(t) }),
|
||||
z.undefined(),
|
||||
]);
|
||||
|
||||
export type Account = {
|
||||
login_name?: string;
|
||||
account_name?: string;
|
||||
email: string;
|
||||
imap: {
|
||||
host: string;
|
||||
port: number;
|
||||
encryption: 'Ssl' | 'StartTls' | 'None';
|
||||
auth: {
|
||||
auth_type: 'Password' | 'OAuth2';
|
||||
password?: string;
|
||||
};
|
||||
use_proxy?: number;
|
||||
};
|
||||
enabled: boolean;
|
||||
use_dangerous: boolean;
|
||||
date_since?: {
|
||||
fixed?: string;
|
||||
relative?: {
|
||||
unit?: 'Days' | 'Months' | 'Years';
|
||||
value?: number;
|
||||
};
|
||||
};
|
||||
date_before?: {
|
||||
unit?: 'Days' | 'Months' | 'Years';
|
||||
value?: number;
|
||||
};
|
||||
folder_limit?: number;
|
||||
download_interval_min: number;
|
||||
download_batch_size: number;
|
||||
auto_download_new_mailboxes: boolean;
|
||||
};
|
||||
|
||||
const getAccountSchema = (isEdit: boolean, t: (key: string) => string) =>
|
||||
z.object({
|
||||
account_name: z.string().optional(),
|
||||
login_name: z.string().optional(),
|
||||
email: z.string({ required_error: t('validation.emailRequired') }).email({ message: t('validation.invalidEmail') }),
|
||||
imap: getImapConfigSchema(isEdit, t),
|
||||
enabled: z.boolean(),
|
||||
use_dangerous: z.boolean(),
|
||||
date_since: getDateSelectionSchema(t).optional(),
|
||||
date_before: getRelativeDateSchema(t).optional(),
|
||||
folder_limit: z
|
||||
.number({ invalid_type_error: t('validation.folderLimitMustBeNumber') })
|
||||
.int()
|
||||
.min(100, { message: t('validation.folderLimitMustBeAtLeast100') })
|
||||
.nullable()
|
||||
.optional(),
|
||||
download_interval_min: z.number({ invalid_type_error: t('validation.incrementalSyncMustBeNumber') }).int().min(10, { message: t('validation.incrementalSyncMustBeAtLeast10') }),
|
||||
download_batch_size: z
|
||||
.number({ invalid_type_error: t('validation.singleRequestBatchSizeMustBeNumber') })
|
||||
.int()
|
||||
.min(10, { message: t('validation.singleRequestBatchSizeTooSmall') })
|
||||
.max(200, { message: t('validation.singleRequestBatchSizeTooLarge') }),
|
||||
auto_download_new_mailboxes: z.boolean(),
|
||||
});
|
||||
export type Account = AccountFormValues;
|
||||
|
||||
type Step = {
|
||||
id: `step-${number}`;
|
||||
@@ -205,6 +101,7 @@ const mapCurrentRowToFormValues = (currentRow: AccountModel): Account => {
|
||||
}
|
||||
|
||||
return {
|
||||
account_name: currentRow.account_name ?? undefined,
|
||||
login_name: currentRow.login_name ?? undefined,
|
||||
email: currentRow.email,
|
||||
imap,
|
||||
|
||||
114
web/src/features/accounts/components/schema.ts
Normal file
114
web/src/features/accounts/components/schema.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
const encryptionSchema = z.union([
|
||||
z.literal('Ssl'),
|
||||
z.literal('StartTls'),
|
||||
z.literal('None'),
|
||||
])
|
||||
|
||||
const authTypeSchema = z.union([
|
||||
z.literal('Password'),
|
||||
z.literal('OAuth2'),
|
||||
])
|
||||
|
||||
export const getAuthConfigSchema = (isEdit: boolean, t: (key: string) => string) =>
|
||||
z
|
||||
.object({
|
||||
auth_type: authTypeSchema,
|
||||
password: z.string().optional(),
|
||||
})
|
||||
.refine(
|
||||
(data) => {
|
||||
if (data.auth_type === 'Password' && !isEdit) {
|
||||
return !!data.password?.trim()
|
||||
}
|
||||
return true
|
||||
},
|
||||
{
|
||||
message: t('validation.passwordRequired'),
|
||||
path: ['password'],
|
||||
}
|
||||
)
|
||||
|
||||
export const getImapConfigSchema = (isEdit: boolean, t: (key: string) => string) =>
|
||||
z.object({
|
||||
host: z
|
||||
.string({ required_error: t('validation.imapHostRequired') })
|
||||
.min(1, { message: t('validation.imapHostCannotBeEmpty') }),
|
||||
port: z
|
||||
.number()
|
||||
.int()
|
||||
.min(0, { message: t('validation.imapPortMustBePositive') })
|
||||
.max(65535, { message: t('validation.imapPortMustBeLessThan65536') }),
|
||||
encryption: encryptionSchema,
|
||||
auth: getAuthConfigSchema(isEdit, t),
|
||||
use_proxy: z.number().optional(),
|
||||
})
|
||||
|
||||
const relativeDateSchema = (t: (key: string) => string) =>
|
||||
z.object({
|
||||
unit: z.enum(['Days', 'Months', 'Years'], {
|
||||
message: t('accounts.selectUnit'),
|
||||
}),
|
||||
value: z
|
||||
.number({ message: t('accounts.enterValue') })
|
||||
.int()
|
||||
.min(1, t('accounts.mustBeAtLeast1')),
|
||||
})
|
||||
|
||||
const dateSelectionSchema = (t: (key: string) => string) =>
|
||||
z
|
||||
.object({
|
||||
fixed: z
|
||||
.string({ message: t('accounts.selectDate') })
|
||||
.min(1, { message: t('accounts.selectDate') })
|
||||
.optional(),
|
||||
relative: relativeDateSchema(t).optional(),
|
||||
})
|
||||
.optional()
|
||||
|
||||
export const getAccountSchema = (isEdit: boolean, t: (key: string) => string) =>
|
||||
z.object({
|
||||
account_name: z.string().optional(),
|
||||
login_name: z.string().optional(),
|
||||
email: z
|
||||
.string({ required_error: t('validation.emailRequired') })
|
||||
.email({ message: t('validation.invalidEmail') }),
|
||||
imap: getImapConfigSchema(isEdit, t),
|
||||
enabled: z.boolean(),
|
||||
use_dangerous: z.boolean(),
|
||||
date_since: dateSelectionSchema(t).optional(),
|
||||
date_before: relativeDateSchema(t).optional(),
|
||||
folder_limit: z
|
||||
.number({ invalid_type_error: t('validation.folderLimitMustBeNumber') })
|
||||
.int()
|
||||
.min(100, { message: t('validation.folderLimitMustBeAtLeast100') })
|
||||
.nullable()
|
||||
.optional(),
|
||||
download_interval_min: z
|
||||
.number({
|
||||
invalid_type_error: t('validation.incrementalSyncMustBeNumber'),
|
||||
})
|
||||
.int()
|
||||
.min(10, {
|
||||
message: t('validation.incrementalSyncMustBeAtLeast10'),
|
||||
}),
|
||||
download_batch_size: z
|
||||
.number({
|
||||
invalid_type_error: t(
|
||||
'validation.singleRequestBatchSizeMustBeNumber'
|
||||
),
|
||||
})
|
||||
.int()
|
||||
.min(10, {
|
||||
message: t('validation.singleRequestBatchSizeTooSmall'),
|
||||
})
|
||||
.max(200, {
|
||||
message: t('validation.singleRequestBatchSizeTooLarge'),
|
||||
}),
|
||||
auto_download_new_mailboxes: z.boolean(),
|
||||
})
|
||||
|
||||
export type AccountFormValues = z.infer<
|
||||
ReturnType<typeof getAccountSchema>
|
||||
>
|
||||
@@ -19,7 +19,7 @@
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { Loader, Download, Trash2, MessageSquareMore, FileText, FileImage, FileVideo, FileArchive, FileSpreadsheet, FileCode, FileIcon, FileAudio, Upload } from 'lucide-react';
|
||||
import { Loader, Download, Trash2, MessageSquareMore, FileText, FileImage, FileVideo, FileArchive, FileSpreadsheet, FileCode, FileIcon, FileAudio, Upload, ShieldCheck } from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
@@ -121,6 +121,12 @@ export function MailMessageView({
|
||||
const [nestedEmlFile, setNestedEmlFile] = useState<AttachmentInfo | null>(null);
|
||||
const { getEmailById } = useMinimalAccountList();
|
||||
const [threadOpen, setThreadOpen] = useState(false);
|
||||
const [blockRemote, setBlockRemote] = useState(true);
|
||||
const [hasRemoteContent, setHasRemoteContent] = useState(false);
|
||||
|
||||
const toggleBlockRemote = () => {
|
||||
setBlockRemote((prev) => !prev);
|
||||
};
|
||||
|
||||
const downloadAttachmentMutation = useMutation({
|
||||
mutationFn: ({ content_hash }: { content_hash: string }) =>
|
||||
@@ -137,12 +143,13 @@ export function MailMessageView({
|
||||
});
|
||||
|
||||
const loadMessageMutation = useMutation({
|
||||
mutationFn: () => load_message(envelope.account_id, envelope.id),
|
||||
mutationFn: () => load_message(envelope.account_id, envelope.id, blockRemote),
|
||||
onSuccess: (data) => {
|
||||
setLoading(false);
|
||||
setContent(getContent(data));
|
||||
if (data.attachments) setAttachments(data.attachments);
|
||||
setContentType(data.html ? 'Html' : 'Plain');
|
||||
setHasRemoteContent(!!data.has_remote_content);
|
||||
},
|
||||
onError: (error: any) => {
|
||||
setLoading(false);
|
||||
@@ -154,10 +161,14 @@ export function MailMessageView({
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setBlockRemote(true);
|
||||
}, [envelope.id]);
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
loadMessageMutation.mutate();
|
||||
}, [envelope.id]);
|
||||
}, [envelope.id, blockRemote]);
|
||||
|
||||
|
||||
const handleViewNestedEml = (attachment: AttachmentInfo) => {
|
||||
@@ -376,6 +387,30 @@ export function MailMessageView({
|
||||
</div>
|
||||
)}
|
||||
{showAttachments && <Separator className="mb-2" />}
|
||||
{hasRemoteContent && (
|
||||
<div className="flex items-center justify-between bg-muted border px-3 py-1.5 mb-3 text-xs">
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
<ShieldCheck className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
|
||||
{blockRemote ? (
|
||||
<span className="text-muted-foreground truncate">
|
||||
{t('mail.remoteBlocked', 'To protect your privacy, Bichon has blocked remote content in this message.')}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground truncate">
|
||||
{t('mail.remoteShown', 'Remote content is now shown.')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span
|
||||
className="underline cursor-pointer hover:no-underline text-muted-foreground text-[11px] font-medium shrink-0 ml-2 select-none"
|
||||
onClick={toggleBlockRemote}
|
||||
>
|
||||
{blockRemote
|
||||
? t('mail.showRemoteContent', 'Show remote content')
|
||||
: t('mail.blockRemoteAgain', 'Block again')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 overflow-auto">
|
||||
{loading ? (
|
||||
<div className="flex justify-center items-center py-8">
|
||||
|
||||
@@ -150,7 +150,7 @@ export function NestedEmailDialog({ open, onOpenChange }: any) {
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['nested-message', currentAttachment?.account_id!, currentAttachment?.envelope_id!, currentAttachment?.content_hash!],
|
||||
queryFn: () => load_nested_message(currentAttachment?.account_id!, currentAttachment?.envelope_id!, currentAttachment?.content_hash!),
|
||||
queryFn: () => load_nested_message(currentAttachment?.account_id!, currentAttachment?.envelope_id!, currentAttachment?.content_hash!, true),
|
||||
enabled: open && !!currentAttachment,
|
||||
});
|
||||
|
||||
|
||||
81
web/src/features/auth/__tests__/schema.test.ts
Normal file
81
web/src/features/auth/__tests__/schema.test.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { getFormSchema } from '../schema'
|
||||
|
||||
// Simple mock t function that returns the key
|
||||
const t = (key: string, _options?: Record<string, any>) => key
|
||||
|
||||
describe('Login Form Schema', () => {
|
||||
const schema = getFormSchema(t)
|
||||
|
||||
describe('username field', () => {
|
||||
it('rejects empty username', () => {
|
||||
const result = schema.safeParse({ username: '', password: 'abcd' })
|
||||
expect(result.success).toBe(false)
|
||||
if (!result.success) {
|
||||
const usernameErrors = result.error.issues.filter(
|
||||
(i) => i.path[0] === 'username'
|
||||
)
|
||||
expect(usernameErrors.length).toBeGreaterThan(0)
|
||||
}
|
||||
})
|
||||
|
||||
it('accepts valid username with password', () => {
|
||||
const result = schema.safeParse({ username: 'admin', password: 'pass1234' })
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts email as username', () => {
|
||||
const result = schema.safeParse({
|
||||
username: 'user@example.com',
|
||||
password: 'mypassword',
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('password field', () => {
|
||||
it('rejects empty password', () => {
|
||||
const result = schema.safeParse({ username: 'admin', password: '' })
|
||||
expect(result.success).toBe(false)
|
||||
if (!result.success) {
|
||||
const passwordErrors = result.error.issues.filter(
|
||||
(i) => i.path[0] === 'password'
|
||||
)
|
||||
expect(passwordErrors.length).toBeGreaterThan(0)
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects password shorter than 4 characters', () => {
|
||||
const result = schema.safeParse({ username: 'admin', password: 'ab' })
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('accepts password of exactly 4 characters', () => {
|
||||
const result = schema.safeParse({
|
||||
username: 'admin',
|
||||
password: 'abcd',
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts long password', () => {
|
||||
const result = schema.safeParse({
|
||||
username: 'admin',
|
||||
password: 'a'.repeat(256),
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('missing fields', () => {
|
||||
it('rejects empty object', () => {
|
||||
const result = schema.safeParse({})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects object with only username', () => {
|
||||
const result = schema.safeParse({ username: 'admin' })
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
16
web/src/features/auth/schema.ts
Normal file
16
web/src/features/auth/schema.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export const getFormSchema = (
|
||||
t: (key: string, options?: Record<string, any>) => string
|
||||
) =>
|
||||
z.object({
|
||||
username: z
|
||||
.string()
|
||||
.min(1, { message: t('validation.pleaseEnterUsernameOrEmail') }),
|
||||
password: z
|
||||
.string()
|
||||
.min(1, { message: t('validation.pleaseEnterPassword') })
|
||||
.min(4, { message: t('validation.passwordMinLength', { min: 4 }) }),
|
||||
})
|
||||
|
||||
export type LoginFormValues = z.infer<ReturnType<typeof getFormSchema>>
|
||||
@@ -18,10 +18,10 @@
|
||||
|
||||
|
||||
import { HTMLAttributes, useState } from 'react'
|
||||
import { z } from 'zod'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { cn, toSearchParams } from '@/lib/utils'
|
||||
import { getFormSchema, type LoginFormValues } from './schema'
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
@@ -47,17 +47,6 @@ import { useTheme } from '@/context/theme-context'
|
||||
|
||||
type UserAuthFormProps = HTMLAttributes<HTMLDivElement>
|
||||
|
||||
const getFormSchema = (t: (key: string, options?: Record<string, any>) => string) =>
|
||||
z.object({
|
||||
username: z
|
||||
.string()
|
||||
.min(1, { message: t('validation.pleaseEnterUsernameOrEmail') }),
|
||||
password: z
|
||||
.string()
|
||||
.min(1, { message: t('validation.pleaseEnterPassword') })
|
||||
.min(4, { message: t('validation.passwordMinLength', { min: 4 }) }),
|
||||
});
|
||||
|
||||
export function UserAuthForm({ className, ...props }: UserAuthFormProps) {
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const { setTheme } = useTheme();
|
||||
@@ -68,7 +57,7 @@ export function UserAuthForm({ className, ...props }: UserAuthFormProps) {
|
||||
const redirect = toSearchParams(search).get('redirect') || '/';
|
||||
|
||||
const formSchema = getFormSchema(t)
|
||||
const form = useForm<z.infer<typeof formSchema>>({
|
||||
const form = useForm<LoginFormValues>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
username: '',
|
||||
@@ -81,7 +70,7 @@ export function UserAuthForm({ className, ...props }: UserAuthFormProps) {
|
||||
retry: 0,
|
||||
});
|
||||
|
||||
async function onSubmit(data: z.infer<typeof formSchema>) {
|
||||
async function onSubmit(data: LoginFormValues) {
|
||||
setIsLoading(true)
|
||||
|
||||
mutation.mutate(data, {
|
||||
|
||||
189
web/src/features/oauth2/components/__tests__/schema.test.ts
Normal file
189
web/src/features/oauth2/components/__tests__/schema.test.ts
Normal file
@@ -0,0 +1,189 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { getOAuth2Schema } from '../schema'
|
||||
|
||||
const t = (key: string) => key
|
||||
|
||||
describe('OAuth2 Form Schema', () => {
|
||||
const schema = getOAuth2Schema(t)
|
||||
|
||||
const validData = {
|
||||
client_id: 'my-client-id',
|
||||
auth_url: 'https://accounts.example.com/o/oauth2/auth',
|
||||
token_url: 'https://oauth2.example.com/token',
|
||||
redirect_uri: 'https://myapp.example.com/oauth2/callback',
|
||||
enabled: true,
|
||||
}
|
||||
|
||||
describe('client_id field', () => {
|
||||
it('rejects empty client_id', () => {
|
||||
const result = schema.safeParse({ ...validData, client_id: '' })
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('accepts valid client_id', () => {
|
||||
const result = schema.safeParse(validData)
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('client_secret field', () => {
|
||||
it('accepts undefined client_secret', () => {
|
||||
const result = schema.safeParse(validData)
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts provided client_secret', () => {
|
||||
const result = schema.safeParse({
|
||||
...validData,
|
||||
client_secret: 'my-secret',
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('auth_url field', () => {
|
||||
it('rejects empty auth_url', () => {
|
||||
const result = schema.safeParse({ ...validData, auth_url: '' })
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects invalid URL format for auth_url', () => {
|
||||
const result = schema.safeParse({
|
||||
...validData,
|
||||
auth_url: 'not-a-url',
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('accepts valid auth_url', () => {
|
||||
const result = schema.safeParse(validData)
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('token_url field', () => {
|
||||
it('rejects empty token_url', () => {
|
||||
const result = schema.safeParse({ ...validData, token_url: '' })
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects invalid URL format for token_url', () => {
|
||||
const result = schema.safeParse({
|
||||
...validData,
|
||||
token_url: 'not-a-url',
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('redirect_uri field', () => {
|
||||
it('rejects empty redirect_uri', () => {
|
||||
const result = schema.safeParse({ ...validData, redirect_uri: '' })
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects invalid URL format for redirect_uri', () => {
|
||||
const result = schema.safeParse({
|
||||
...validData,
|
||||
redirect_uri: 'not-a-url',
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('scopes field', () => {
|
||||
it('accepts empty scopes array', () => {
|
||||
const result = schema.safeParse({ ...validData, scopes: [] })
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts valid scopes', () => {
|
||||
const result = schema.safeParse({
|
||||
...validData,
|
||||
scopes: [{ value: 'https://mail.google.com/' }],
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects scope with empty value', () => {
|
||||
const result = schema.safeParse({
|
||||
...validData,
|
||||
scopes: [{ value: '' }],
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('extra_params field', () => {
|
||||
it('accepts empty extra_params array', () => {
|
||||
const result = schema.safeParse({ ...validData, extra_params: [] })
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts valid extra_params', () => {
|
||||
const result = schema.safeParse({
|
||||
...validData,
|
||||
extra_params: [{ key: 'access_type', value: 'offline' }],
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects param with empty key', () => {
|
||||
const result = schema.safeParse({
|
||||
...validData,
|
||||
extra_params: [{ key: '', value: 'offline' }],
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects param with empty value', () => {
|
||||
const result = schema.safeParse({
|
||||
...validData,
|
||||
extra_params: [{ key: 'access_type', value: '' }],
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('enabled field', () => {
|
||||
it('accepts enabled: true', () => {
|
||||
const result = schema.safeParse(validData)
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts enabled: false', () => {
|
||||
const result = schema.safeParse({ ...validData, enabled: false })
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('description field', () => {
|
||||
it('rejects description longer than 255 characters', () => {
|
||||
const result = schema.safeParse({
|
||||
...validData,
|
||||
description: 'a'.repeat(256),
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('accepts description of exactly 255 characters', () => {
|
||||
const result = schema.safeParse({
|
||||
...validData,
|
||||
description: 'a'.repeat(255),
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('use_proxy field', () => {
|
||||
it('accepts undefined use_proxy', () => {
|
||||
const result = schema.safeParse(validData)
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts numeric use_proxy', () => {
|
||||
const result = schema.safeParse({ ...validData, use_proxy: 1 })
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -17,7 +17,6 @@
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
import { z } from 'zod'
|
||||
import { useFieldArray, useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { toast } from '@/hooks/use-toast'
|
||||
@@ -53,115 +52,22 @@ import { AxiosError } from 'axios'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import useProxyList from '@/hooks/use-proxy'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
const getParamSchema = (t: (key: string) => string) => z.object({
|
||||
key: z.string({ required_error: t('oauth2.keyIsRequired') }).min(1, t('oauth2.keyCannotBeEmpty')),
|
||||
value: z.string({ required_error: t('oauth2.valueIsRequired') }).min(1, t('oauth2.valueCannotBeEmpty')),
|
||||
});
|
||||
|
||||
const paramSchema = z.object({
|
||||
key: z.string({ required_error: 'Key is required' }).min(1, "Key cannot be empty"),
|
||||
value: z.string({ required_error: 'Value is required' }).min(1, "Value cannot be empty"),
|
||||
});
|
||||
|
||||
const getScopeSchema = (t: (key: string) => string) => z.object({
|
||||
value: z.string({ required_error: t('oauth2.valueIsRequired') }).min(1, t('oauth2.valueCannotBeEmpty')),
|
||||
});
|
||||
|
||||
const scopeSchema = z.object({
|
||||
value: z.string({ required_error: 'Value is required' }).min(1, "Value cannot be empty"),
|
||||
});
|
||||
|
||||
const extraparamSchema = z.record(z.string()).optional();
|
||||
const authorizescopeSchema = z.array(z.string()).optional();
|
||||
|
||||
import { getOAuth2Schema, type OAuth2FormValues } from './schema'
|
||||
|
||||
function convertToExtraParamsSchema(
|
||||
record: z.infer<typeof extraparamSchema>
|
||||
): z.infer<typeof paramSchema>[] {
|
||||
if (!record) {
|
||||
return [];
|
||||
}
|
||||
return Object.entries(record).map(([key, value]) => ({
|
||||
key,
|
||||
value,
|
||||
}));
|
||||
record: Record<string, string> | undefined
|
||||
): { key: string; value: string }[] {
|
||||
if (!record) return []
|
||||
return Object.entries(record).map(([key, value]) => ({ key, value }))
|
||||
}
|
||||
|
||||
|
||||
function convertToScopeSchema(authorizeScopes: z.infer<typeof authorizescopeSchema>): z.infer<typeof scopeSchema>[] {
|
||||
if (!authorizeScopes || authorizeScopes.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return authorizeScopes.map((scope) => ({
|
||||
value: scope,
|
||||
}));
|
||||
function convertToScopeSchema(
|
||||
scopes: string[] | undefined
|
||||
): { value: string }[] {
|
||||
if (!scopes || scopes.length === 0) return []
|
||||
return scopes.map((scope) => ({ value: scope }))
|
||||
}
|
||||
|
||||
const getOAuth2Schema = (t: (key: string) => string) => z.object({
|
||||
description: z.string().max(255, { message: t('oauth2.descriptionMustNotExceed255Characters') }).optional(),
|
||||
client_id: z.string({
|
||||
required_error: t('oauth2.clientIdIsRequired'),
|
||||
}).min(1, { message: t('oauth2.clientIdCannotBeEmpty') }),
|
||||
client_secret: z.string().optional(),
|
||||
auth_url: z.string({
|
||||
required_error: t('oauth2.authorizationUrlIsRequired'),
|
||||
})
|
||||
.min(1, { message: t('oauth2.authorizationUrlCannotBeEmpty') })
|
||||
.url({ message: t('oauth2.invalidAuthorizationUrlFormat') }),
|
||||
|
||||
token_url: z.string({
|
||||
required_error: t('oauth2.tokenUrlIsRequired'),
|
||||
})
|
||||
.min(1, { message: t('oauth2.tokenUrlCannotBeEmpty') })
|
||||
.url({ message: t('oauth2.invalidTokenUrlFormat') }),
|
||||
|
||||
redirect_uri: z.string({
|
||||
required_error: t('oauth2.redirectUriIsRequired'),
|
||||
})
|
||||
.min(1, { message: t('oauth2.redirectUriCannotBeEmpty') })
|
||||
.url({ message: t('oauth2.invalidRedirectUriFormat') }),
|
||||
|
||||
scopes: z.array(getScopeSchema(t)).optional(),
|
||||
extra_params: z.array(getParamSchema(t)).optional(),
|
||||
enabled: z.boolean(),
|
||||
use_proxy: z.number().optional(),
|
||||
});
|
||||
|
||||
const oauth2Schema = z.object({
|
||||
description: z.string().max(255, { message: "Description must not exceed 255 characters." }).optional(),
|
||||
client_id: z.string({
|
||||
required_error: "Client ID is required",
|
||||
}).min(1, { message: "Client ID cannot be empty" }),
|
||||
client_secret: z.string().optional(),
|
||||
auth_url: z.string({
|
||||
required_error: "Authorization URL is required",
|
||||
})
|
||||
.min(1, { message: "Authorization URL cannot be empty" })
|
||||
.url({ message: "Invalid Authorization URL format" }),
|
||||
|
||||
token_url: z.string({
|
||||
required_error: "Token URL is required",
|
||||
})
|
||||
.min(1, { message: "Token URL cannot be empty" })
|
||||
.url({ message: "Invalid Token URL format" }),
|
||||
|
||||
redirect_uri: z.string({
|
||||
required_error: "Redirect URI is required",
|
||||
})
|
||||
.min(1, { message: "Redirect URI cannot be empty" })
|
||||
.url({ message: "Invalid Redirect URI format" }),
|
||||
|
||||
scopes: z.array(scopeSchema).optional(),
|
||||
extra_params: z.array(paramSchema).optional(),
|
||||
enabled: z.boolean(),
|
||||
use_proxy: z.number().optional(),
|
||||
});
|
||||
|
||||
export type OAuth2Form = z.infer<typeof oauth2Schema>;
|
||||
|
||||
|
||||
interface Props {
|
||||
currentRow?: OAuth2Entity
|
||||
open: boolean
|
||||
@@ -185,7 +91,7 @@ const defaultValues = {
|
||||
export function ActionDialog({ currentRow, open, onOpenChange }: Props) {
|
||||
const { t } = useTranslation()
|
||||
const isEdit = !!currentRow
|
||||
const form = useForm<OAuth2Form>({
|
||||
const form = useForm<OAuth2FormValues>({
|
||||
resolver: zodResolver(getOAuth2Schema(t)),
|
||||
defaultValues: isEdit
|
||||
? {
|
||||
@@ -255,7 +161,7 @@ export function ActionDialog({ currentRow, open, onOpenChange }: Props) {
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
const onSubmit = (values: OAuth2Form) => {
|
||||
const onSubmit = (values: OAuth2FormValues) => {
|
||||
if (!isEdit) {
|
||||
if (!values.client_secret) {
|
||||
form.setError('client_secret', {
|
||||
|
||||
56
web/src/features/oauth2/components/schema.ts
Normal file
56
web/src/features/oauth2/components/schema.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
const paramEntry = (t: (key: string) => string) =>
|
||||
z.object({
|
||||
key: z
|
||||
.string({ required_error: t('oauth2.keyIsRequired') })
|
||||
.min(1, t('oauth2.keyCannotBeEmpty')),
|
||||
value: z
|
||||
.string({ required_error: t('oauth2.valueIsRequired') })
|
||||
.min(1, t('oauth2.valueCannotBeEmpty')),
|
||||
})
|
||||
|
||||
const scopeEntry = (t: (key: string) => string) =>
|
||||
z.object({
|
||||
value: z
|
||||
.string({ required_error: t('oauth2.valueIsRequired') })
|
||||
.min(1, t('oauth2.valueCannotBeEmpty')),
|
||||
})
|
||||
|
||||
export const getOAuth2Schema = (t: (key: string) => string) =>
|
||||
z.object({
|
||||
description: z
|
||||
.string()
|
||||
.max(255, { message: t('oauth2.descriptionMustNotExceed255Characters') })
|
||||
.optional(),
|
||||
client_id: z
|
||||
.string({
|
||||
required_error: t('oauth2.clientIdIsRequired'),
|
||||
})
|
||||
.min(1, { message: t('oauth2.clientIdCannotBeEmpty') }),
|
||||
client_secret: z.string().optional(),
|
||||
auth_url: z
|
||||
.string({
|
||||
required_error: t('oauth2.authorizationUrlIsRequired'),
|
||||
})
|
||||
.min(1, { message: t('oauth2.authorizationUrlCannotBeEmpty') })
|
||||
.url({ message: t('oauth2.invalidAuthorizationUrlFormat') }),
|
||||
token_url: z
|
||||
.string({
|
||||
required_error: t('oauth2.tokenUrlIsRequired'),
|
||||
})
|
||||
.min(1, { message: t('oauth2.tokenUrlCannotBeEmpty') })
|
||||
.url({ message: t('oauth2.invalidTokenUrlFormat') }),
|
||||
redirect_uri: z
|
||||
.string({
|
||||
required_error: t('oauth2.redirectUriIsRequired'),
|
||||
})
|
||||
.min(1, { message: t('oauth2.redirectUriCannotBeEmpty') })
|
||||
.url({ message: t('oauth2.invalidRedirectUriFormat') }),
|
||||
scopes: z.array(scopeEntry(t)).optional(),
|
||||
extra_params: z.array(paramEntry(t)).optional(),
|
||||
enabled: z.boolean(),
|
||||
use_proxy: z.number().optional(),
|
||||
})
|
||||
|
||||
export type OAuth2FormValues = z.infer<ReturnType<typeof getOAuth2Schema>>
|
||||
@@ -19,7 +19,7 @@
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { Loader, Download, Trash2, MessageSquareMore, FileText, FileImage, FileVideo, FileArchive, FileSpreadsheet, FileCode, FileIcon, FileAudio, Upload } from 'lucide-react';
|
||||
import { Loader, Download, Trash2, MessageSquareMore, FileText, FileImage, FileVideo, FileArchive, FileSpreadsheet, FileCode, FileIcon, FileAudio, Upload, ShieldCheck } from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
@@ -129,6 +129,12 @@ export function MailMessageView({
|
||||
const [nestedEmlFile, setNestedEmlFile] = useState<AttachmentInfo | null>(null);
|
||||
const { getEmailById } = useMinimalAccountList();
|
||||
const [threadOpen, setThreadOpen] = useState(false);
|
||||
const [blockRemote, setBlockRemote] = useState(true);
|
||||
const [hasRemoteContent, setHasRemoteContent] = useState(false);
|
||||
|
||||
const toggleBlockRemote = () => {
|
||||
setBlockRemote((prev) => !prev);
|
||||
};
|
||||
|
||||
const downloadAttachmentMutation = useMutation({
|
||||
mutationFn: ({ content_hash }: { content_hash: string }) =>
|
||||
@@ -145,12 +151,13 @@ export function MailMessageView({
|
||||
});
|
||||
|
||||
const loadMessageMutation = useMutation({
|
||||
mutationFn: () => load_message(envelope.account_id, envelope.id),
|
||||
mutationFn: () => load_message(envelope.account_id, envelope.id, blockRemote),
|
||||
onSuccess: (data) => {
|
||||
setLoading(false);
|
||||
setContent(getContent(data));
|
||||
if (data.attachments) setAttachments(data.attachments);
|
||||
setContentType(data.html ? 'Html' : 'Plain');
|
||||
setHasRemoteContent(!!data.has_remote_content);
|
||||
},
|
||||
onError: (error: any) => {
|
||||
setLoading(false);
|
||||
@@ -162,10 +169,14 @@ export function MailMessageView({
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setBlockRemote(true);
|
||||
}, [envelope.id]);
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
loadMessageMutation.mutate();
|
||||
}, [envelope.id]);
|
||||
}, [envelope.id, blockRemote]);
|
||||
|
||||
|
||||
const handleViewNestedEml = (attachment: AttachmentInfo) => {
|
||||
@@ -384,6 +395,30 @@ export function MailMessageView({
|
||||
</div>
|
||||
)}
|
||||
{showAttachments && <Separator className="mb-2" />}
|
||||
{hasRemoteContent && (
|
||||
<div className="flex items-center justify-between bg-muted border px-3 py-1.5 mb-3 text-xs">
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
<ShieldCheck className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
|
||||
{blockRemote ? (
|
||||
<span className="text-muted-foreground truncate">
|
||||
{t('mail.remoteBlocked', 'To protect your privacy, Bichon has blocked remote content in this message.')}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground truncate">
|
||||
{t('mail.remoteShown', 'Remote content is now shown.')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span
|
||||
className="underline cursor-pointer hover:no-underline text-muted-foreground text-[11px] font-medium shrink-0 ml-2 select-none"
|
||||
onClick={toggleBlockRemote}
|
||||
>
|
||||
{blockRemote
|
||||
? t('mail.showRemoteContent', 'Show remote content')
|
||||
: t('mail.blockRemoteAgain', 'Block again')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 overflow-auto">
|
||||
{loading ? (
|
||||
<div className="flex justify-center items-center py-8">
|
||||
|
||||
@@ -148,7 +148,7 @@ export function NestedEmailDialog({ open, onOpenChange, accountId, envelopeId, f
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['nested-message', accountId, envelopeId, content_hash],
|
||||
queryFn: () => load_nested_message(accountId, envelopeId, content_hash),
|
||||
queryFn: () => load_nested_message(accountId, envelopeId, content_hash, true),
|
||||
enabled: open && !!content_hash,
|
||||
});
|
||||
|
||||
|
||||
153
web/src/features/settings/profile/__tests__/schema.test.ts
Normal file
153
web/src/features/settings/profile/__tests__/schema.test.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { profileSchema } from '../schema'
|
||||
|
||||
const t = (key: string) => key
|
||||
|
||||
describe('Profile Form Schema', () => {
|
||||
const schema = profileSchema(t)
|
||||
|
||||
describe('username field', () => {
|
||||
it('rejects empty username', () => {
|
||||
const result = schema.safeParse({
|
||||
username: '',
|
||||
email: 'user@example.com',
|
||||
password: '',
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
if (!result.success) {
|
||||
const errors = result.error.issues.filter(
|
||||
(i) => i.path[0] === 'username'
|
||||
)
|
||||
expect(errors.length).toBeGreaterThan(0)
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects username shorter than 3 characters', () => {
|
||||
const result = schema.safeParse({
|
||||
username: 'ab',
|
||||
email: 'user@example.com',
|
||||
password: '',
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('accepts username of exactly 3 characters', () => {
|
||||
const result = schema.safeParse({
|
||||
username: 'abc',
|
||||
email: 'user@example.com',
|
||||
password: '',
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects username longer than 32 characters', () => {
|
||||
const result = schema.safeParse({
|
||||
username: 'a'.repeat(33),
|
||||
email: 'user@example.com',
|
||||
password: '',
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('accepts username of exactly 32 characters', () => {
|
||||
const result = schema.safeParse({
|
||||
username: 'a'.repeat(32),
|
||||
email: 'user@example.com',
|
||||
password: '',
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('email field', () => {
|
||||
it('rejects empty email', () => {
|
||||
const result = schema.safeParse({
|
||||
username: 'validuser',
|
||||
email: '',
|
||||
password: '',
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects invalid email format', () => {
|
||||
const result = schema.safeParse({
|
||||
username: 'validuser',
|
||||
email: 'not-an-email',
|
||||
password: '',
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects email without domain', () => {
|
||||
const result = schema.safeParse({
|
||||
username: 'validuser',
|
||||
email: 'user@',
|
||||
password: '',
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('accepts valid email', () => {
|
||||
const result = schema.safeParse({
|
||||
username: 'validuser',
|
||||
email: 'user@example.com',
|
||||
password: '',
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('password field', () => {
|
||||
it('accepts empty password (keep current)', () => {
|
||||
const result = schema.safeParse({
|
||||
username: 'validuser',
|
||||
email: 'user@example.com',
|
||||
password: '',
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
if (result.success) {
|
||||
// Empty password should be transformed to undefined
|
||||
expect(result.data.password).toBeUndefined()
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects password shorter than 8 characters when provided', () => {
|
||||
const result = schema.safeParse({
|
||||
username: 'validuser',
|
||||
email: 'user@example.com',
|
||||
password: 'short',
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('accepts password of exactly 8 characters', () => {
|
||||
const result = schema.safeParse({
|
||||
username: 'validuser',
|
||||
email: 'user@example.com',
|
||||
password: '12345678',
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects password longer than 256 characters', () => {
|
||||
const result = schema.safeParse({
|
||||
username: 'validuser',
|
||||
email: 'user@example.com',
|
||||
password: 'a'.repeat(257),
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('transforms non-empty password to the string value', () => {
|
||||
const result = schema.safeParse({
|
||||
username: 'validuser',
|
||||
email: 'user@example.com',
|
||||
password: 'myNewPassword123',
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
if (result.success) {
|
||||
expect(result.data.password).toBe('myNewPassword123')
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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/>.
|
||||
|
||||
import { z } from 'zod'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
@@ -42,41 +41,7 @@ import { Badge } from '@/components/ui/badge'
|
||||
import { FileWithPreview } from '@/hooks/use-file-upload'
|
||||
import AvatarUpload from './avatar-upload'
|
||||
import { PermissionsDialog } from '../access/permissions-dialog'
|
||||
|
||||
const profileSchema = (t: (key: string) => string) => z.object({
|
||||
username: z
|
||||
.string({
|
||||
required_error: t('settings.profile.validation.username.required'),
|
||||
})
|
||||
.min(3, {
|
||||
message: t('settings.profile.validation.username.min'),
|
||||
})
|
||||
.max(32, {
|
||||
message: t('settings.profile.validation.username.max'),
|
||||
}),
|
||||
|
||||
email: z
|
||||
.string({
|
||||
required_error: t('settings.profile.validation.email.required'),
|
||||
})
|
||||
.email({
|
||||
message: t('settings.profile.validation.email.invalid'),
|
||||
}),
|
||||
|
||||
password: z
|
||||
.string()
|
||||
.min(8, {
|
||||
message: t('settings.profile.validation.password.min'),
|
||||
})
|
||||
.max(256, {
|
||||
message: t('settings.profile.validation.password.max'),
|
||||
})
|
||||
.or(z.literal(''))
|
||||
.optional()
|
||||
.transform((v) => (v ? v : undefined)),
|
||||
})
|
||||
|
||||
export type ProfileFormValues = z.infer<ReturnType<typeof profileSchema>>
|
||||
import { profileSchema, type ProfileFormValues } from './schema'
|
||||
|
||||
function fileToBase64(file: File): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
|
||||
37
web/src/features/settings/profile/schema.ts
Normal file
37
web/src/features/settings/profile/schema.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export const profileSchema = (t: (key: string) => string) =>
|
||||
z.object({
|
||||
username: z
|
||||
.string({
|
||||
required_error: t('settings.profile.validation.username.required'),
|
||||
})
|
||||
.min(3, {
|
||||
message: t('settings.profile.validation.username.min'),
|
||||
})
|
||||
.max(32, {
|
||||
message: t('settings.profile.validation.username.max'),
|
||||
}),
|
||||
|
||||
email: z
|
||||
.string({
|
||||
required_error: t('settings.profile.validation.email.required'),
|
||||
})
|
||||
.email({
|
||||
message: t('settings.profile.validation.email.invalid'),
|
||||
}),
|
||||
|
||||
password: z
|
||||
.string()
|
||||
.min(8, {
|
||||
message: t('settings.profile.validation.password.min'),
|
||||
})
|
||||
.max(256, {
|
||||
message: t('settings.profile.validation.password.max'),
|
||||
})
|
||||
.or(z.literal(''))
|
||||
.optional()
|
||||
.transform((v) => (v ? v : undefined)),
|
||||
})
|
||||
|
||||
export type ProfileFormValues = z.infer<ReturnType<typeof profileSchema>>
|
||||
@@ -0,0 +1,173 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { proxyFormSchema } from '../schema'
|
||||
|
||||
describe('Proxy Form Schema', () => {
|
||||
describe('url field - basic validation', () => {
|
||||
it('rejects empty URL', () => {
|
||||
const result = proxyFormSchema.safeParse({ url: '' })
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('accepts valid socks5 URL', () => {
|
||||
const result = proxyFormSchema.safeParse({
|
||||
url: 'socks5://127.0.0.1:1080',
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts valid http URL', () => {
|
||||
const result = proxyFormSchema.safeParse({
|
||||
url: 'http://proxy.example.com:8080',
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('url field - protocol validation', () => {
|
||||
it('rejects https protocol', () => {
|
||||
const result = proxyFormSchema.safeParse({
|
||||
url: 'https://proxy.example.com:443',
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
if (!result.success) {
|
||||
expect(
|
||||
result.error.issues.some((i) =>
|
||||
i.message?.includes('http:// or socks5://')
|
||||
)
|
||||
).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects ftp protocol', () => {
|
||||
const result = proxyFormSchema.safeParse({
|
||||
url: 'ftp://files.example.com',
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects URL without protocol', () => {
|
||||
const result = proxyFormSchema.safeParse({
|
||||
url: '127.0.0.1:1080',
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
if (!result.success) {
|
||||
expect(
|
||||
result.error.issues.some((i) =>
|
||||
i.message?.includes('Invalid URL format')
|
||||
)
|
||||
).toBe(true)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('url field - port validation', () => {
|
||||
it('rejects port 0', () => {
|
||||
const result = proxyFormSchema.safeParse({
|
||||
url: 'socks5://127.0.0.1:0',
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects port > 65535', () => {
|
||||
const result = proxyFormSchema.safeParse({
|
||||
url: 'socks5://127.0.0.1:99999',
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('accepts port 65535', () => {
|
||||
const result = proxyFormSchema.safeParse({
|
||||
url: 'socks5://127.0.0.1:65535',
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts port 1', () => {
|
||||
const result = proxyFormSchema.safeParse({
|
||||
url: 'socks5://127.0.0.1:1',
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('defaults to port 1080 when no port specified', () => {
|
||||
const result = proxyFormSchema.safeParse({
|
||||
url: 'socks5://127.0.0.1',
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('url field - hostname validation', () => {
|
||||
it('accepts IP address hostname', () => {
|
||||
const result = proxyFormSchema.safeParse({
|
||||
url: 'socks5://192.168.1.1:1080',
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts domain hostname', () => {
|
||||
const result = proxyFormSchema.safeParse({
|
||||
url: 'socks5://proxy.internal:1080',
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects hostname with invalid characters', () => {
|
||||
const result = proxyFormSchema.safeParse({
|
||||
url: 'socks5://proxy_host:1080',
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
if (!result.success) {
|
||||
expect(
|
||||
result.error.issues.some((i) =>
|
||||
i.message?.includes('Hostname contains invalid characters')
|
||||
)
|
||||
).toBe(true)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('url field - auth validation', () => {
|
||||
it('rejects username without password', () => {
|
||||
const result = proxyFormSchema.safeParse({
|
||||
url: 'socks5://user@127.0.0.1:1080',
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
if (!result.success) {
|
||||
expect(
|
||||
result.error.issues.some((i) =>
|
||||
i.message?.includes('Password cannot be empty')
|
||||
)
|
||||
).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects short password when username provided', () => {
|
||||
const result = proxyFormSchema.safeParse({
|
||||
url: 'socks5://user:short@127.0.0.1:1080',
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
if (!result.success) {
|
||||
expect(
|
||||
result.error.issues.some((i) =>
|
||||
i.message?.includes('Password must be at least 8')
|
||||
)
|
||||
).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('accepts valid auth credentials', () => {
|
||||
const result = proxyFormSchema.safeParse({
|
||||
url: 'socks5://user:password123@127.0.0.1:1080',
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts URL without auth (no credentials)', () => {
|
||||
const result = proxyFormSchema.safeParse({
|
||||
url: 'socks5://127.0.0.1:1080',
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -17,7 +17,6 @@
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
import { z } from 'zod'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { toast } from '@/hooks/use-toast'
|
||||
@@ -47,75 +46,7 @@ import { Loader2 } from 'lucide-react'
|
||||
import { add_proxy, update_proxy } from '@/api/system/api'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Proxy } from '@/api/system/api'
|
||||
|
||||
const proxyFormSchema = z.object({
|
||||
url: z.string()
|
||||
.min(1, "Proxy address cannot be empty")
|
||||
.superRefine((value, ctx) => {
|
||||
|
||||
if (value.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(value);
|
||||
} catch (e) {
|
||||
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "Invalid URL format",
|
||||
path: [],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
if (url.protocol !== 'socks5:' && url.protocol !== 'http:') {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "URL must start with http:// or socks5://",
|
||||
path: [],
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
if (!/^[a-zA-Z0-9\-\.]+$/.test(url.hostname)) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "Hostname contains invalid characters",
|
||||
path: [],
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
const port = parseInt(url.port || '1080');
|
||||
if (port <= 0 || port > 65535) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "Port must be between 1-65535",
|
||||
path: [],
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
if (url.username && !url.password) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "Password cannot be empty when username is provided",
|
||||
path: [],
|
||||
});
|
||||
} else if (url.password && url.password.length < 8) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "Password must be at least 8 characters",
|
||||
path: [],
|
||||
});
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
export type ProxyForm = z.infer<typeof proxyFormSchema>;
|
||||
import { proxyFormSchema, type ProxyFormValues } from './schema'
|
||||
|
||||
|
||||
interface Props {
|
||||
@@ -140,7 +71,7 @@ export function ProxyActionDialog({ currentRow, open, onOpenChange }: Props) {
|
||||
const { t } = useTranslation()
|
||||
const isEdit = !!currentRow
|
||||
const queryClient = useQueryClient();
|
||||
const form = useForm<ProxyForm>({
|
||||
const form = useForm<ProxyFormValues>({
|
||||
resolver: zodResolver(proxyFormSchema),
|
||||
defaultValues: isEdit
|
||||
? mapCurrentRowToFormValues(currentRow)
|
||||
@@ -186,7 +117,7 @@ export function ProxyActionDialog({ currentRow, open, onOpenChange }: Props) {
|
||||
}
|
||||
|
||||
|
||||
const onSubmit = (values: ProxyForm) => {
|
||||
const onSubmit = (values: ProxyFormValues) => {
|
||||
const url = values.url;
|
||||
if (isEdit) {
|
||||
updateMutation.mutate(url);
|
||||
|
||||
65
web/src/features/settings/proxy/components/schema.ts
Normal file
65
web/src/features/settings/proxy/components/schema.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export const proxyFormSchema = z.object({
|
||||
url: z
|
||||
.string()
|
||||
.min(1, 'Proxy address cannot be empty')
|
||||
.superRefine((value, ctx) => {
|
||||
if (value.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
let url: URL
|
||||
try {
|
||||
url = new URL(value)
|
||||
} catch (_e) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'Invalid URL format',
|
||||
path: [],
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (url.protocol !== 'socks5:' && url.protocol !== 'http:') {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'URL must start with http:// or socks5://',
|
||||
path: [],
|
||||
})
|
||||
}
|
||||
|
||||
if (!/^[a-zA-Z0-9\-\.]+$/.test(url.hostname)) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'Hostname contains invalid characters',
|
||||
path: [],
|
||||
})
|
||||
}
|
||||
|
||||
const port = parseInt(url.port || '1080')
|
||||
if (port <= 0 || port > 65535) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'Port must be between 1-65535',
|
||||
path: [],
|
||||
})
|
||||
}
|
||||
|
||||
if (url.username && !url.password) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'Password cannot be empty when username is provided',
|
||||
path: [],
|
||||
})
|
||||
} else if (url.password && url.password.length < 8) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'Password must be at least 8 characters',
|
||||
path: [],
|
||||
})
|
||||
}
|
||||
}),
|
||||
})
|
||||
|
||||
export type ProxyFormValues = z.infer<typeof proxyFormSchema>
|
||||
129
web/src/features/users/roles/components/__tests__/schema.test.ts
Normal file
129
web/src/features/users/roles/components/__tests__/schema.test.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { getRoleFormSchema } from '../schema'
|
||||
|
||||
const t = (key: string) => key
|
||||
|
||||
describe('Role Form Schema', () => {
|
||||
const schema = getRoleFormSchema(t)
|
||||
|
||||
describe('name field', () => {
|
||||
it('rejects empty name', () => {
|
||||
const result = schema.safeParse({
|
||||
name: '',
|
||||
role_type: 'Account',
|
||||
permissions: ['data:read'],
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('accepts valid name', () => {
|
||||
const result = schema.safeParse({
|
||||
name: 'Viewer',
|
||||
role_type: 'Account',
|
||||
permissions: ['data:read'],
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('role_type field', () => {
|
||||
it('accepts Global role type', () => {
|
||||
const result = schema.safeParse({
|
||||
name: 'Admin',
|
||||
role_type: 'Global',
|
||||
permissions: ['system:access'],
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts Account role type', () => {
|
||||
const result = schema.safeParse({
|
||||
name: 'Viewer',
|
||||
role_type: 'Account',
|
||||
permissions: ['data:read'],
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects invalid role type', () => {
|
||||
const result = schema.safeParse({
|
||||
name: 'Test',
|
||||
role_type: 'Invalid',
|
||||
permissions: ['data:read'],
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('permissions field', () => {
|
||||
it('rejects empty permissions array', () => {
|
||||
const result = schema.safeParse({
|
||||
name: 'Viewer',
|
||||
role_type: 'Account',
|
||||
permissions: [],
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('accepts single permission', () => {
|
||||
const result = schema.safeParse({
|
||||
name: 'Viewer',
|
||||
role_type: 'Account',
|
||||
permissions: ['data:read'],
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts multiple permissions', () => {
|
||||
const result = schema.safeParse({
|
||||
name: 'Manager',
|
||||
role_type: 'Account',
|
||||
permissions: ['data:read', 'data:manage', 'account:manage'],
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts all available permissions', () => {
|
||||
const result = schema.safeParse({
|
||||
name: 'Super Admin',
|
||||
role_type: 'Global',
|
||||
permissions: [
|
||||
'system:access',
|
||||
'system:root',
|
||||
'user:manage',
|
||||
'user:view',
|
||||
'token:manage',
|
||||
'account:create',
|
||||
'account:manage:all',
|
||||
'data:read:all',
|
||||
'data:manage:all',
|
||||
'data:raw:download:all',
|
||||
'data:delete:all',
|
||||
'data:export:batch:all',
|
||||
],
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('description field', () => {
|
||||
it('accepts undefined description', () => {
|
||||
const result = schema.safeParse({
|
||||
name: 'Viewer',
|
||||
role_type: 'Account',
|
||||
permissions: ['data:read'],
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts description string', () => {
|
||||
const result = schema.safeParse({
|
||||
name: 'Viewer',
|
||||
role_type: 'Account',
|
||||
permissions: ['data:read'],
|
||||
description: 'Read-only access to data',
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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/>.
|
||||
|
||||
import { z } from 'zod'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { toast } from '@/hooks/use-toast'
|
||||
@@ -49,6 +48,7 @@ import {
|
||||
} from '@/components/ui/radio-group'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { getRoleFormSchema, type RoleFormValues } from './schema'
|
||||
|
||||
interface Props {
|
||||
currentRow?: UserRole
|
||||
@@ -97,16 +97,9 @@ export function RoleActionDialog({ currentRow, open, onOpenChange }: Props) {
|
||||
const queryClient = useQueryClient()
|
||||
const { t } = useTranslation()
|
||||
|
||||
const roleFormSchema = z.object({
|
||||
name: z.string().min(1, t('roles.validation.name_required')),
|
||||
role_type: z.enum(['Global', 'Account']),
|
||||
permissions: z.array(z.string()).min(1, t('roles.validation.perm_required')),
|
||||
description: z.string().optional(),
|
||||
})
|
||||
const roleFormSchema = getRoleFormSchema(t)
|
||||
|
||||
type RoleForm = z.infer<typeof roleFormSchema>
|
||||
|
||||
const form = useForm<RoleForm>({
|
||||
const form = useForm<RoleFormValues>({
|
||||
resolver: zodResolver(roleFormSchema),
|
||||
defaultValues: {
|
||||
name: isEdit ? currentRow.name : '',
|
||||
@@ -117,7 +110,7 @@ export function RoleActionDialog({ currentRow, open, onOpenChange }: Props) {
|
||||
})
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: (values: RoleForm) =>
|
||||
mutationFn: (values: RoleFormValues) =>
|
||||
isEdit ? update_role(currentRow!.id, values) : create_role(values),
|
||||
onSuccess: () => {
|
||||
toast({ title: t(isEdit ? 'roles.actions.success_update' : 'roles.actions.success_create') })
|
||||
|
||||
11
web/src/features/users/roles/components/schema.ts
Normal file
11
web/src/features/users/roles/components/schema.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export const getRoleFormSchema = (t: (key: string) => string) =>
|
||||
z.object({
|
||||
name: z.string().min(1, t('roles.validation.name_required')),
|
||||
role_type: z.enum(['Global', 'Account']),
|
||||
permissions: z.array(z.string()).min(1, t('roles.validation.perm_required')),
|
||||
description: z.string().optional(),
|
||||
})
|
||||
|
||||
export type RoleFormValues = z.infer<ReturnType<typeof getRoleFormSchema>>
|
||||
308
web/src/features/users/users/components/__tests__/schema.test.ts
Normal file
308
web/src/features/users/users/components/__tests__/schema.test.ts
Normal file
@@ -0,0 +1,308 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { getCreateUserSchema, getUpdateUserSchema } from '../schema'
|
||||
|
||||
const t = (key: string) => key
|
||||
|
||||
const validBaseUser = {
|
||||
username: 'johndoe',
|
||||
email: 'john@example.com',
|
||||
global_roles: [1],
|
||||
}
|
||||
|
||||
const validCreateUser = {
|
||||
...validBaseUser,
|
||||
password: 'securePassword123',
|
||||
}
|
||||
|
||||
const invalidCases = [
|
||||
{ desc: 'empty username', data: { ...validCreateUser, username: '' } },
|
||||
{
|
||||
desc: 'username shorter than 3',
|
||||
data: { ...validCreateUser, username: 'ab' },
|
||||
},
|
||||
{
|
||||
desc: 'username longer than 32',
|
||||
data: { ...validCreateUser, username: 'a'.repeat(33) },
|
||||
},
|
||||
{ desc: 'empty email', data: { ...validCreateUser, email: '' } },
|
||||
{
|
||||
desc: 'invalid email format',
|
||||
data: { ...validCreateUser, email: 'not-an-email' },
|
||||
},
|
||||
{
|
||||
desc: 'empty global_roles',
|
||||
data: { ...validCreateUser, global_roles: [] },
|
||||
},
|
||||
{
|
||||
desc: 'empty password on create',
|
||||
data: { ...validBaseUser, password: '' },
|
||||
},
|
||||
{
|
||||
desc: 'short password on create',
|
||||
data: { ...validBaseUser, password: 'short' },
|
||||
},
|
||||
{
|
||||
desc: 'password longer than 256 on create',
|
||||
data: { ...validBaseUser, password: 'a'.repeat(257) },
|
||||
},
|
||||
]
|
||||
|
||||
describe('Create User Schema', () => {
|
||||
const schema = getCreateUserSchema(t)
|
||||
|
||||
it('accepts valid user data', () => {
|
||||
const result = schema.safeParse(validCreateUser)
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it.each(invalidCases)('rejects $desc', ({ data }) => {
|
||||
const result = schema.safeParse(data)
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('accepts username of exactly 3 characters', () => {
|
||||
const result = schema.safeParse({
|
||||
...validCreateUser,
|
||||
username: 'abc',
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts username of exactly 32 characters', () => {
|
||||
const result = schema.safeParse({
|
||||
...validCreateUser,
|
||||
username: 'a'.repeat(32),
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts password of exactly 8 characters', () => {
|
||||
const result = schema.safeParse({
|
||||
...validBaseUser,
|
||||
password: '12345678',
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts password of exactly 256 characters', () => {
|
||||
const result = schema.safeParse({
|
||||
...validBaseUser,
|
||||
password: 'a'.repeat(256),
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Update User Schema', () => {
|
||||
const schema = getUpdateUserSchema(t)
|
||||
|
||||
it('accepts empty password (keep current)', () => {
|
||||
const result = schema.safeParse({
|
||||
...validBaseUser,
|
||||
password: '',
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
if (result.success) {
|
||||
expect(result.data.password).toBeUndefined()
|
||||
}
|
||||
})
|
||||
|
||||
it('accepts undefined password', () => {
|
||||
const result = schema.safeParse(validBaseUser)
|
||||
expect(result.success).toBe(true)
|
||||
if (result.success) {
|
||||
expect(result.data.password).toBeUndefined()
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects short password when provided', () => {
|
||||
const result = schema.safeParse({
|
||||
...validBaseUser,
|
||||
password: 'short',
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('accepts valid password when provided', () => {
|
||||
const result = schema.safeParse({
|
||||
...validBaseUser,
|
||||
password: 'newPassword123',
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
if (result.success) {
|
||||
expect(result.data.password).toBe('newPassword123')
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('User Schema - ACL', () => {
|
||||
const schema = getCreateUserSchema(t)
|
||||
|
||||
describe('ip_whitelist validation', () => {
|
||||
it('accepts valid IPv4 addresses', () => {
|
||||
const result = schema.safeParse({
|
||||
...validCreateUser,
|
||||
acl: { ip_whitelist: '192.168.1.1\n10.0.0.1' },
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts valid IPv6 address', () => {
|
||||
const result = schema.safeParse({
|
||||
...validCreateUser,
|
||||
acl: { ip_whitelist: '2001:0db8:85a3:0000:0000:8a2e:0370:7334' },
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects invalid IP format', () => {
|
||||
const result = schema.safeParse({
|
||||
...validCreateUser,
|
||||
acl: { ip_whitelist: 'not-an-ip' },
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects invalid IP with too many octets', () => {
|
||||
const result = schema.safeParse({
|
||||
...validCreateUser,
|
||||
acl: { ip_whitelist: '192.168.1.1.1' },
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects IP with octet > 255', () => {
|
||||
const result = schema.safeParse({
|
||||
...validCreateUser,
|
||||
acl: { ip_whitelist: '300.1.1.1' },
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('accepts empty ACL (no security policies)', () => {
|
||||
const result = schema.safeParse(validCreateUser)
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('rate_limit validation', () => {
|
||||
it('accepts valid rate_limit', () => {
|
||||
const result = schema.safeParse({
|
||||
...validCreateUser,
|
||||
acl: {
|
||||
rate_limit: { quota: 100, interval: 60 },
|
||||
},
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('transforms ACL with only rate_limit', () => {
|
||||
const result = schema.safeParse({
|
||||
...validCreateUser,
|
||||
acl: {
|
||||
rate_limit: { quota: 100, interval: 60 },
|
||||
},
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
if (result.success && result.data.acl) {
|
||||
expect(result.data.acl.rate_limit).toBeDefined()
|
||||
expect(result.data.acl.rate_limit!.quota).toBe(100)
|
||||
expect(result.data.acl.ip_whitelist).toBeUndefined()
|
||||
}
|
||||
})
|
||||
|
||||
it('returns undefined for ACL with only empty ip_whitelist', () => {
|
||||
const result = schema.safeParse({
|
||||
...validCreateUser,
|
||||
acl: { ip_whitelist: '' },
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
if (result.success) {
|
||||
expect(result.data.acl).toBeUndefined()
|
||||
}
|
||||
})
|
||||
|
||||
it('returns undefined for ACL with no data', () => {
|
||||
const result = schema.safeParse({
|
||||
...validCreateUser,
|
||||
acl: { ip_whitelist: '\n\n' },
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
if (result.success) {
|
||||
expect(result.data.acl).toBeUndefined()
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('User Schema - account_access_entries', () => {
|
||||
const schema = getCreateUserSchema(t)
|
||||
|
||||
it('accepts empty account_access_entries (defaults to [])', () => {
|
||||
const result = schema.safeParse(validCreateUser)
|
||||
expect(result.success).toBe(true)
|
||||
if (result.success) {
|
||||
expect(result.data.account_access_entries).toEqual([])
|
||||
}
|
||||
})
|
||||
|
||||
it('accepts valid account access entries', () => {
|
||||
const result = schema.safeParse({
|
||||
...validCreateUser,
|
||||
account_access_entries: [
|
||||
{ accountId: 1, roleId: 2 },
|
||||
{ accountId: 3, roleId: 4 },
|
||||
],
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects entry with accountId 0', () => {
|
||||
const result = schema.safeParse({
|
||||
...validCreateUser,
|
||||
account_access_entries: [{ accountId: 0, roleId: 1 }],
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects entry with roleId 0', () => {
|
||||
const result = schema.safeParse({
|
||||
...validCreateUser,
|
||||
account_access_entries: [{ accountId: 1, roleId: 0 }],
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('User Schema - description', () => {
|
||||
const schema = getCreateUserSchema(t)
|
||||
|
||||
it('accepts undefined description', () => {
|
||||
const result = schema.safeParse(validCreateUser)
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts empty string description', () => {
|
||||
const result = schema.safeParse({
|
||||
...validCreateUser,
|
||||
description: '',
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts valid description', () => {
|
||||
const result = schema.safeParse({
|
||||
...validCreateUser,
|
||||
description: 'A test user account',
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects description longer than 256 characters', () => {
|
||||
const result = schema.safeParse({
|
||||
...validCreateUser,
|
||||
description: 'a'.repeat(257),
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -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/>.
|
||||
|
||||
import { z } from 'zod'
|
||||
import { useState, useMemo } from 'react'
|
||||
import { useFieldArray, useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
@@ -56,75 +55,9 @@ import { useRoles } from '@/hooks/use-roles'
|
||||
import { PasswordInput } from '@/components/password-input'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { getCreateUserSchema, getUpdateUserSchema, type UserFormValues } from './schema'
|
||||
|
||||
const isValidIP = (ip: string) => {
|
||||
const ipv4 = /^(?:(?:\d{1,3}\.){3}\d{1,3})$/
|
||||
const ipv6 = /^([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}$/
|
||||
return ipv4.test(ip) || ipv6.test(ip)
|
||||
}
|
||||
|
||||
const accountAccessEntry = (t: any) => z.object({
|
||||
accountId: z.number().min(1, t('users.actions.schema.account_required')),
|
||||
roleId: z.number().min(1, t('users.actions.schema.role_required'))
|
||||
});
|
||||
|
||||
const baseUserSchema = (t: any) => ({
|
||||
username: z.string()
|
||||
.min(1, t('users.actions.schema.username_required'))
|
||||
.min(3, t('users.actions.schema.username_min'))
|
||||
.max(32, t('users.actions.schema.username_max')),
|
||||
email: z.string()
|
||||
.min(1, t('users.actions.schema.email_required'))
|
||||
.email(t('users.actions.schema.email_invalid')),
|
||||
global_roles: z.array(z.number()).min(1, t('users.actions.schema.global_role_required')),
|
||||
account_access_entries: z.array(accountAccessEntry(t)).optional().default([]),
|
||||
description: z.string().max(256, t('users.actions.schema.description_max')).optional().or(z.literal('')),
|
||||
acl: z.object({
|
||||
ip_whitelist: z.string().optional(),
|
||||
rate_limit: z.object({
|
||||
quota: z.number().positive().optional(),
|
||||
interval: z.number().positive().optional(),
|
||||
}).optional()
|
||||
}).optional().transform((data) => {
|
||||
if (!data) return undefined;
|
||||
const ips = data.ip_whitelist?.split('\n').map(v => v.trim()).filter(Boolean) || [];
|
||||
const finalRateLimit = (data.rate_limit?.quota && data.rate_limit?.interval)
|
||||
? data.rate_limit
|
||||
: undefined;
|
||||
if (ips.length === 0 && !finalRateLimit) return undefined;
|
||||
return {
|
||||
ip_whitelist: ips.length > 0 ? ips.join('\n') : undefined,
|
||||
rate_limit: finalRateLimit
|
||||
};
|
||||
})
|
||||
.refine((data) => {
|
||||
if (!data?.ip_whitelist) return true;
|
||||
return data.ip_whitelist.split('\n').every(isValidIP);
|
||||
}, {
|
||||
message: t('users.actions.schema.ip_invalid'),
|
||||
path: ["ip_whitelist"]
|
||||
})
|
||||
});
|
||||
|
||||
const createUserSchema = (t: any) => z.object({
|
||||
...baseUserSchema(t),
|
||||
password: z.string()
|
||||
.min(1, t('users.actions.schema.password_required'))
|
||||
.min(8, t('users.actions.schema.password_min'))
|
||||
.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(256, t('users.actions.schema.password_max'))
|
||||
.or(z.literal(''))
|
||||
.optional()
|
||||
.transform(v => v || undefined),
|
||||
});
|
||||
|
||||
export type UserForm = z.infer<ReturnType<typeof createUserSchema>> | z.infer<ReturnType<typeof updateUserSchema>>
|
||||
export type UserForm = UserFormValues
|
||||
|
||||
interface Props {
|
||||
currentRow?: User
|
||||
@@ -142,7 +75,7 @@ export function UserActionDialog({ currentRow, open, onOpenChange }: Props) {
|
||||
const { minimalList: allAccounts } = useMinimalAccountList()
|
||||
|
||||
const form = useForm<UserForm>({
|
||||
resolver: zodResolver(isEdit ? updateUserSchema(t) : createUserSchema(t)),
|
||||
resolver: zodResolver(isEdit ? getUpdateUserSchema(t) : getCreateUserSchema(t)),
|
||||
defaultValues: useMemo(() => {
|
||||
if (isEdit && currentRow) {
|
||||
const accessEntries = currentRow.account_access_map
|
||||
|
||||
106
web/src/features/users/users/components/schema.ts
Normal file
106
web/src/features/users/users/components/schema.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export const accountAccessEntry = (t: (key: string) => string) =>
|
||||
z.object({
|
||||
accountId: z.number().min(1, t('users.actions.schema.account_required')),
|
||||
roleId: z.number().min(1, t('users.actions.schema.role_required')),
|
||||
})
|
||||
|
||||
const isValidIPv4 = (ip: string): boolean => {
|
||||
const parts = ip.split('.')
|
||||
if (parts.length !== 4) return false
|
||||
return parts.every((part) => {
|
||||
const num = Number(part)
|
||||
return part === String(num) && num >= 0 && num <= 255
|
||||
})
|
||||
}
|
||||
|
||||
const isValidIP = (ip: string) => {
|
||||
const ipv6 = /^([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}$/
|
||||
return isValidIPv4(ip) || ipv6.test(ip)
|
||||
}
|
||||
|
||||
export const getBaseUserSchema = (t: (key: string) => string) =>
|
||||
z.object({
|
||||
username: z
|
||||
.string()
|
||||
.min(1, t('users.actions.schema.username_required'))
|
||||
.min(3, t('users.actions.schema.username_min'))
|
||||
.max(32, t('users.actions.schema.username_max')),
|
||||
email: z
|
||||
.string()
|
||||
.min(1, t('users.actions.schema.email_required'))
|
||||
.email(t('users.actions.schema.email_invalid')),
|
||||
global_roles: z
|
||||
.array(z.number())
|
||||
.min(1, t('users.actions.schema.global_role_required')),
|
||||
account_access_entries: z
|
||||
.array(accountAccessEntry(t))
|
||||
.optional()
|
||||
.default([]),
|
||||
description: z
|
||||
.string()
|
||||
.max(256, t('users.actions.schema.description_max'))
|
||||
.optional()
|
||||
.or(z.literal('')),
|
||||
acl: z
|
||||
.object({
|
||||
ip_whitelist: z.string().optional(),
|
||||
rate_limit: z
|
||||
.object({
|
||||
quota: z.number().positive().optional(),
|
||||
interval: z.number().positive().optional(),
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
.optional()
|
||||
.transform((data) => {
|
||||
if (!data) return undefined
|
||||
const ips =
|
||||
data.ip_whitelist
|
||||
?.split('\n')
|
||||
.map((v) => v.trim())
|
||||
.filter(Boolean) || []
|
||||
const finalRateLimit =
|
||||
data.rate_limit?.quota && data.rate_limit?.interval
|
||||
? data.rate_limit
|
||||
: undefined
|
||||
if (ips.length === 0 && !finalRateLimit) return undefined
|
||||
return {
|
||||
ip_whitelist: ips.length > 0 ? ips.join('\n') : undefined,
|
||||
rate_limit: finalRateLimit,
|
||||
}
|
||||
})
|
||||
.refine(
|
||||
(data) => {
|
||||
if (!data?.ip_whitelist) return true
|
||||
return data.ip_whitelist.split('\n').every(isValidIP)
|
||||
},
|
||||
{
|
||||
message: t('users.actions.schema.ip_invalid'),
|
||||
path: ['ip_whitelist'],
|
||||
}
|
||||
),
|
||||
})
|
||||
|
||||
export const getCreateUserSchema = (t: (key: string) => string) =>
|
||||
getBaseUserSchema(t).extend({
|
||||
password: z
|
||||
.string()
|
||||
.min(1, t('users.actions.schema.password_required'))
|
||||
.min(8, t('users.actions.schema.password_min'))
|
||||
.max(256, t('users.actions.schema.password_max')),
|
||||
})
|
||||
|
||||
export const getUpdateUserSchema = (t: (key: string) => string) =>
|
||||
getBaseUserSchema(t).extend({
|
||||
password: z
|
||||
.string()
|
||||
.min(8, t('users.actions.schema.password_min'))
|
||||
.max(256, t('users.actions.schema.password_max'))
|
||||
.or(z.literal(''))
|
||||
.optional()
|
||||
.transform((v) => v || undefined),
|
||||
})
|
||||
|
||||
export type UserFormValues = z.infer<ReturnType<typeof getCreateUserSchema>>
|
||||
@@ -100,6 +100,7 @@
|
||||
"auth": "المصادقة",
|
||||
"authType": "نوع_المصادقة",
|
||||
"autoConfiguring": "جارٍ التكوين التلقائي...",
|
||||
"autoDownloadNewMailboxes": "إضافة المجلدات الجديدة تلقائيًا",
|
||||
"autoDownloadNewMailboxesDescription": "إضافة المجلدات الجديدة المكتشفة تلقائيًا إلى قائمة التنزيل.",
|
||||
"beforeRelative": "تنزيل رسائل البريد الإلكتروني القديمة فقط",
|
||||
"beforeRelativeValue": "تنزيل رسائل البريد الإلكتروني قبل {{value}} {{unit}} مضت",
|
||||
@@ -556,6 +557,7 @@
|
||||
"account": "الحساب",
|
||||
"attachments": "المرفقات",
|
||||
"bcc": "نسخة مخفية",
|
||||
"blockRemoteAgain": "حظر مجدداً",
|
||||
"cc": "نسخة",
|
||||
"clickToDownload": "انقر للتنزيل",
|
||||
"date": "التاريخ",
|
||||
@@ -575,8 +577,11 @@
|
||||
"noMessageSelected": "لم يتم تحديد رسالة",
|
||||
"noTagsYet": "لا توجد علامات بعد",
|
||||
"onlyNonInlineAttachments": "يتم عرض المرفقات غير المضمنة فقط هنا.",
|
||||
"remoteBlocked": "لحماية خصوصيتك، قام Bichon بحظر المحتوى الخارجي في هذه الرسالة.",
|
||||
"remoteShown": "تم عرض المحتوى الخارجي.",
|
||||
"showLess": "إظهار أقل",
|
||||
"showMore": "إظهار المزيد...",
|
||||
"showRemoteContent": "عرض المحتوى الخارجي",
|
||||
"subject": "الموضوع",
|
||||
"tags": "العلامات",
|
||||
"to": "إلى",
|
||||
|
||||
@@ -100,6 +100,7 @@
|
||||
"auth": "Godkendelse",
|
||||
"authType": "godkendelsestype",
|
||||
"autoConfiguring": "Konfigurerer automatisk...",
|
||||
"autoDownloadNewMailboxes": "Tilføj automatisk nye mapper",
|
||||
"autoDownloadNewMailboxesDescription": "Føj automatisk nye mapper til downloadlisten.",
|
||||
"beforeRelative": "Download kun gamle e-mails",
|
||||
"beforeRelativeValue": "Download e-mails før {{value}} {{unit}} siden",
|
||||
@@ -556,6 +557,7 @@
|
||||
"account": "Konto",
|
||||
"attachments": "Vedhæftninger",
|
||||
"bcc": "Blindkopi",
|
||||
"blockRemoteAgain": "Bloker igen",
|
||||
"cc": "Kopi",
|
||||
"clickToDownload": "Klik for at downloade",
|
||||
"date": "Dato",
|
||||
@@ -575,8 +577,11 @@
|
||||
"noMessageSelected": "Ingen meddelelse valgt",
|
||||
"noTagsYet": "Ingen etiketter endnu",
|
||||
"onlyNonInlineAttachments": "Kun ikke-indlejrede vedhæftninger vises her.",
|
||||
"remoteBlocked": "For at beskytte dit privatliv har Bichon blokeret eksternt indhold i denne meddelelse.",
|
||||
"remoteShown": "Eksternt indhold vises nu.",
|
||||
"showLess": "vis mindre",
|
||||
"showMore": "vis mere...",
|
||||
"showRemoteContent": "Vis eksternt indhold",
|
||||
"subject": "Emne",
|
||||
"tags": "Etiketter",
|
||||
"to": "Til",
|
||||
|
||||
@@ -100,6 +100,7 @@
|
||||
"auth": "Authentifizierung",
|
||||
"authType": "Authentifizierungstyp",
|
||||
"autoConfiguring": "Automatische Konfiguration läuft...",
|
||||
"autoDownloadNewMailboxes": "Neue Ordner automatisch hinzufügen",
|
||||
"autoDownloadNewMailboxesDescription": "Neu entdeckte Ordner automatisch zur Download-Liste hinzufügen.",
|
||||
"beforeRelative": "Nur alte E-Mails herunterladen",
|
||||
"beforeRelativeValue": "E-Mails von vor {{value}} {{unit}} herunterladen",
|
||||
@@ -556,6 +557,7 @@
|
||||
"account": "Konto",
|
||||
"attachments": "Anhänge",
|
||||
"bcc": "BCC",
|
||||
"blockRemoteAgain": "Wieder blockieren",
|
||||
"cc": "CC",
|
||||
"clickToDownload": "Zum Herunterladen klicken",
|
||||
"date": "Datum",
|
||||
@@ -575,8 +577,11 @@
|
||||
"noMessageSelected": "Keine Nachricht ausgewählt",
|
||||
"noTagsYet": "Noch keine Tags",
|
||||
"onlyNonInlineAttachments": "Es werden nur Nicht-Inline-Anhänge hier angezeigt.",
|
||||
"remoteBlocked": "Zum Schutz Ihrer Privatsphäre hat Bichon externe Inhalte in dieser Nachricht blockiert.",
|
||||
"remoteShown": "Externe Inhalte werden angezeigt.",
|
||||
"showLess": "weniger anzeigen",
|
||||
"showMore": "mehr anzeigen...",
|
||||
"showRemoteContent": "Externe Inhalte anzeigen",
|
||||
"subject": "Betreff",
|
||||
"tags": "Tags",
|
||||
"to": "An",
|
||||
|
||||
@@ -557,6 +557,7 @@
|
||||
"account": "Account",
|
||||
"attachments": "Attachments",
|
||||
"bcc": "BCC",
|
||||
"blockRemoteAgain": "Block again",
|
||||
"cc": "CC",
|
||||
"clickToDownload": "Click to download",
|
||||
"date": "Date",
|
||||
@@ -576,8 +577,11 @@
|
||||
"noMessageSelected": "No message selected",
|
||||
"noTagsYet": "No tags yet",
|
||||
"onlyNonInlineAttachments": "Only non-inline attachments are shown here.",
|
||||
"remoteBlocked": "To protect your privacy, Bichon has blocked remote content in this message.",
|
||||
"remoteShown": "Remote content is now shown.",
|
||||
"showLess": "show less",
|
||||
"showMore": "show more...",
|
||||
"showRemoteContent": "Show remote content",
|
||||
"subject": "Subject",
|
||||
"tags": "Tags",
|
||||
"to": "To",
|
||||
|
||||
@@ -100,6 +100,7 @@
|
||||
"auth": "Autenticación",
|
||||
"authType": "tipo de autenticación",
|
||||
"autoConfiguring": "Autoconfigurando...",
|
||||
"autoDownloadNewMailboxes": "Añadir automáticamente nuevas carpetas",
|
||||
"autoDownloadNewMailboxesDescription": "Añadir automáticamente las nuevas carpetas a la lista de descarga.",
|
||||
"beforeRelative": "Descargar solo correos antiguos",
|
||||
"beforeRelativeValue": "Descargar correos de hace {{value}} {{unit}}",
|
||||
@@ -556,6 +557,7 @@
|
||||
"account": "Cuenta",
|
||||
"attachments": "Adjuntos",
|
||||
"bcc": "CCO",
|
||||
"blockRemoteAgain": "Bloquear de nuevo",
|
||||
"cc": "CC",
|
||||
"clickToDownload": "Hacer clic para descargar",
|
||||
"date": "Fecha",
|
||||
@@ -575,8 +577,11 @@
|
||||
"noMessageSelected": "Ningún mensaje seleccionado",
|
||||
"noTagsYet": "Aún no hay etiquetas",
|
||||
"onlyNonInlineAttachments": "Solo se muestran aquí los adjuntos que no están en línea.",
|
||||
"remoteBlocked": "Para proteger tu privacidad, Bichon ha bloqueado el contenido remoto de este mensaje.",
|
||||
"remoteShown": "Contenido remoto mostrado.",
|
||||
"showLess": "mostrar menos",
|
||||
"showMore": "mostrar más...",
|
||||
"showRemoteContent": "Mostrar contenido remoto",
|
||||
"subject": "Asunto",
|
||||
"tags": "Etiquetas",
|
||||
"to": "Para",
|
||||
|
||||
@@ -100,6 +100,7 @@
|
||||
"auth": "Todennus",
|
||||
"authType": "todennustyyppi",
|
||||
"autoConfiguring": "Automaattinen määritys...",
|
||||
"autoDownloadNewMailboxes": "Lisää uudet kansiot automaattisesti",
|
||||
"autoDownloadNewMailboxesDescription": "Lisää uudet löydetyt kansiot automaattisesti latausluetteloon.",
|
||||
"beforeRelative": "Lataa vain vanhat sähköpostit",
|
||||
"beforeRelativeValue": "Lataa sähköpostit {{value}} {{unit}} sitten",
|
||||
@@ -556,6 +557,7 @@
|
||||
"account": "Tili",
|
||||
"attachments": "Liitteet",
|
||||
"bcc": "Piilokopio",
|
||||
"blockRemoteAgain": "Estä uudelleen",
|
||||
"cc": "Kopio",
|
||||
"clickToDownload": "Napsauta ladataksesi",
|
||||
"date": "Päivämäärä",
|
||||
@@ -575,8 +577,11 @@
|
||||
"noMessageSelected": "Ei valittua viestiä",
|
||||
"noTagsYet": "Ei tunnisteita vielä",
|
||||
"onlyNonInlineAttachments": "Vain muut kuin sisäiset liitteet näytetään tässä.",
|
||||
"remoteBlocked": "Tietoturvasi vuoksi Bichon on estänyt etäsisällön tässä viestissä.",
|
||||
"remoteShown": "Etäsisältö näytetään nyt.",
|
||||
"showLess": "näytä vähemmän",
|
||||
"showMore": "näytä lisää...",
|
||||
"showRemoteContent": "Näytä etäsisältö",
|
||||
"subject": "Aihe",
|
||||
"tags": "Tunnisteet",
|
||||
"to": "Vastaanottaja",
|
||||
|
||||
@@ -100,6 +100,7 @@
|
||||
"auth": "Auth.",
|
||||
"authType": "type_auth",
|
||||
"autoConfiguring": "Configuration automatique...",
|
||||
"autoDownloadNewMailboxes": "Ajouter automatiquement les nouveaux dossiers",
|
||||
"autoDownloadNewMailboxesDescription": "Ajouter automatiquement les nouveaux dossiers à la liste de téléchargement.",
|
||||
"beforeRelative": "Télécharger uniquement les anciens e-mails",
|
||||
"beforeRelativeValue": "Télécharger les e-mails d'il y a {{value}} {{unit}}",
|
||||
@@ -556,6 +557,7 @@
|
||||
"account": "Compte",
|
||||
"attachments": "Pièces jointes",
|
||||
"bcc": "Cci",
|
||||
"blockRemoteAgain": "Bloquer à nouveau",
|
||||
"cc": "Cc",
|
||||
"clickToDownload": "Cliquer pour télécharger",
|
||||
"date": "Date",
|
||||
@@ -575,8 +577,11 @@
|
||||
"noMessageSelected": "Aucun message sélectionné",
|
||||
"noTagsYet": "Aucune étiquette pour l'instant",
|
||||
"onlyNonInlineAttachments": "Seules les pièces jointes non intégrées sont affichées ici.",
|
||||
"remoteBlocked": "Pour protéger votre vie privée, Bichon a bloqué le contenu distant dans ce message.",
|
||||
"remoteShown": "Contenu distant affiché.",
|
||||
"showLess": "afficher moins",
|
||||
"showMore": "afficher plus...",
|
||||
"showRemoteContent": "Afficher le contenu distant",
|
||||
"subject": "Objet",
|
||||
"tags": "Étiquettes",
|
||||
"to": "À",
|
||||
|
||||
@@ -100,6 +100,7 @@
|
||||
"auth": "Autenticazione",
|
||||
"authType": "tipo_autenticazione",
|
||||
"autoConfiguring": "Configurazione automatica...",
|
||||
"autoDownloadNewMailboxes": "Aggiungi automaticamente nuove cartelle",
|
||||
"autoDownloadNewMailboxesDescription": "Aggiungi automaticamente le nuove cartelle all'elenco di download.",
|
||||
"beforeRelative": "Scarica solo le vecchie email",
|
||||
"beforeRelativeValue": "Scarica email da {{value}} {{unit}} fa",
|
||||
@@ -556,6 +557,7 @@
|
||||
"account": "Account",
|
||||
"attachments": "Allegati",
|
||||
"bcc": "BCC",
|
||||
"blockRemoteAgain": "Blocca di nuovo",
|
||||
"cc": "CC",
|
||||
"clickToDownload": "Clicca per scaricare",
|
||||
"date": "Data",
|
||||
@@ -575,8 +577,11 @@
|
||||
"noMessageSelected": "Nessun messaggio selezionato",
|
||||
"noTagsYet": "Ancora nessun tag",
|
||||
"onlyNonInlineAttachments": "Solo gli allegati non in linea sono mostrati qui.",
|
||||
"remoteBlocked": "Per proteggere la tua privacy, Bichon ha bloccato i contenuti remoti in questo messaggio.",
|
||||
"remoteShown": "Contenuto remoto mostrato.",
|
||||
"showLess": "mostra meno",
|
||||
"showMore": "mostra altro...",
|
||||
"showRemoteContent": "Mostra contenuto remoto",
|
||||
"subject": "Oggetto",
|
||||
"tags": "Tag",
|
||||
"to": "A",
|
||||
|
||||
@@ -100,6 +100,7 @@
|
||||
"auth": "認証",
|
||||
"authType": "認証タイプ",
|
||||
"autoConfiguring": "自動設定中...",
|
||||
"autoDownloadNewMailboxes": "新着フォルダーの自動追加",
|
||||
"autoDownloadNewMailboxesDescription": "新しく見つかったフォルダーを自動的にダウンロード一覧に追加します。",
|
||||
"beforeRelative": "古いメールのみダウンロード",
|
||||
"beforeRelativeValue": "{{value}} {{unit}}前より古いメールをダウンロード",
|
||||
@@ -556,6 +557,7 @@
|
||||
"account": "アカウント",
|
||||
"attachments": "添付ファイル",
|
||||
"bcc": "BCC",
|
||||
"blockRemoteAgain": "再度ブロック",
|
||||
"cc": "CC",
|
||||
"clickToDownload": "クリックしてダウンロード",
|
||||
"date": "日付",
|
||||
@@ -575,8 +577,11 @@
|
||||
"noMessageSelected": "メッセージが選択されていません",
|
||||
"noTagsYet": "まだタグがありません",
|
||||
"onlyNonInlineAttachments": "インラインではない添付ファイルのみここに表示されます。",
|
||||
"remoteBlocked": "プライバシー保護のため、Bichonはこのメッセージ内のリモートコンテンツをブロックしました。",
|
||||
"remoteShown": "リモートコンテンツを表示しています。",
|
||||
"showLess": "少なく表示",
|
||||
"showMore": "さらに表示...",
|
||||
"showRemoteContent": "リモートコンテンツを表示",
|
||||
"subject": "件名",
|
||||
"tags": "タグ",
|
||||
"to": "宛先",
|
||||
|
||||
@@ -100,6 +100,7 @@
|
||||
"auth": "인증",
|
||||
"authType": "인증 유형",
|
||||
"autoConfiguring": "자동 구성 중...",
|
||||
"autoDownloadNewMailboxes": "새 폴더 자동 추가",
|
||||
"autoDownloadNewMailboxesDescription": "새로 발견된 폴더를 다운로드 목록에 자동으로 추가합니다.",
|
||||
"beforeRelative": "이전 이메일만 다운로드",
|
||||
"beforeRelativeValue": "{{value}} {{unit}} 전의 이메일 다운로드",
|
||||
@@ -556,6 +557,7 @@
|
||||
"account": "계정",
|
||||
"attachments": "첨부 파일",
|
||||
"bcc": "숨은 참조",
|
||||
"blockRemoteAgain": "다시 차단",
|
||||
"cc": "참조",
|
||||
"clickToDownload": "클릭하여 다운로드",
|
||||
"date": "날짜",
|
||||
@@ -575,8 +577,11 @@
|
||||
"noMessageSelected": "선택된 메시지 없음",
|
||||
"noTagsYet": "아직 태그가 없습니다",
|
||||
"onlyNonInlineAttachments": "인라인이 아닌 첨부 파일만 여기에 표시됩니다.",
|
||||
"remoteBlocked": "개인 정보 보호를 위해 Bichon이 이 메시지의 원격 콘텐츠를 차단했습니다.",
|
||||
"remoteShown": "원격 콘텐츠가 표시됩니다.",
|
||||
"showLess": "간단히 보기",
|
||||
"showMore": "더 보기...",
|
||||
"showRemoteContent": "원격 콘텐츠 표시",
|
||||
"subject": "제목",
|
||||
"tags": "태그",
|
||||
"to": "받는 사람",
|
||||
|
||||
@@ -100,6 +100,7 @@
|
||||
"auth": "Authenticatie",
|
||||
"authType": "authenticatie_type",
|
||||
"autoConfiguring": "Automatisch configureren...",
|
||||
"autoDownloadNewMailboxes": "Nieuwe mappen automatisch importeren",
|
||||
"autoDownloadNewMailboxesDescription": "Voeg automatisch nieuw ontdekte mappen toe aan de downloadlijst.",
|
||||
"beforeRelative": "Download alleen oude e-mails",
|
||||
"beforeRelativeValue": "Download e-mails van {{value}} {{unit}} geleden",
|
||||
@@ -556,6 +557,7 @@
|
||||
"account": "Account",
|
||||
"attachments": "Bijlagen",
|
||||
"bcc": "BCC",
|
||||
"blockRemoteAgain": "Opnieuw blokkeren",
|
||||
"cc": "CC",
|
||||
"clickToDownload": "Klik om te downloaden",
|
||||
"date": "Datum",
|
||||
@@ -575,8 +577,11 @@
|
||||
"noMessageSelected": "Geen bericht geselecteerd",
|
||||
"noTagsYet": "Nog geen tags",
|
||||
"onlyNonInlineAttachments": "Alleen niet-inline bijlagen worden hier getoond.",
|
||||
"remoteBlocked": "Om uw privacy te beschermen, heeft Bichon externe inhoud in dit bericht geblokkeerd.",
|
||||
"remoteShown": "Externe inhoud wordt nu weergegeven.",
|
||||
"showLess": "toon minder",
|
||||
"showMore": "toon meer...",
|
||||
"showRemoteContent": "Externe inhoud weergeven",
|
||||
"subject": "Onderwerp",
|
||||
"tags": "Tags",
|
||||
"to": "Aan",
|
||||
|
||||
@@ -100,6 +100,7 @@
|
||||
"auth": "Autentisering",
|
||||
"authType": "autentiseringstype",
|
||||
"autoConfiguring": "Konfigurerer automatisk...",
|
||||
"autoDownloadNewMailboxes": "Legg til nye mapper automatisk",
|
||||
"autoDownloadNewMailboxesDescription": "Legg automatisk til nye mapper i nedlastingslisten.",
|
||||
"beforeRelative": "Last ned kun gamle e-poster",
|
||||
"beforeRelativeValue": "Last ned e-poster fra {{value}} {{unit}} siden",
|
||||
@@ -556,6 +557,7 @@
|
||||
"account": "Konto",
|
||||
"attachments": "Vedlegg",
|
||||
"bcc": "Blindkopi",
|
||||
"blockRemoteAgain": "Blokker igjen",
|
||||
"cc": "Kopi",
|
||||
"clickToDownload": "Klikk for å laste ned",
|
||||
"date": "Dato",
|
||||
@@ -575,8 +577,11 @@
|
||||
"noMessageSelected": "Ingen melding valgt",
|
||||
"noTagsYet": "Ingen etiketter ennå",
|
||||
"onlyNonInlineAttachments": "Kun ikke-innebygde vedlegg vises her.",
|
||||
"remoteBlocked": "For å beskytte personvernet ditt har Bichon blokkert eksternt innhold i denne meldingen.",
|
||||
"remoteShown": "Eksternt innhold vises nå.",
|
||||
"showLess": "vis mindre",
|
||||
"showMore": "vis mer...",
|
||||
"showRemoteContent": "Vis eksternt indhold",
|
||||
"subject": "Emne",
|
||||
"tags": "Etiketter",
|
||||
"to": "Til",
|
||||
|
||||
@@ -100,6 +100,7 @@
|
||||
"auth": "Auth",
|
||||
"authType": "auth_type",
|
||||
"autoConfiguring": "Auto konfiguracja...",
|
||||
"autoDownloadNewMailboxes": "Automatycznie dodawaj nowe foldery",
|
||||
"autoDownloadNewMailboxesDescription": "Automatycznie dodawaj nowo wykryte foldery do listy pobierania.",
|
||||
"beforeRelative": "Pobierz tylko stare e-maile",
|
||||
"beforeRelativeValue": "Pobierz e-maile sprzed {{value}} {{unit}}",
|
||||
@@ -556,6 +557,7 @@
|
||||
"account": "Konto",
|
||||
"attachments": "Załączniki",
|
||||
"bcc": "UDW",
|
||||
"blockRemoteAgain": "Zablokuj ponownie",
|
||||
"cc": "DW",
|
||||
"clickToDownload": "Kliknij, aby pobrać",
|
||||
"date": "Data",
|
||||
@@ -575,8 +577,11 @@
|
||||
"noMessageSelected": "Nie zaznaczono wiadomości",
|
||||
"noTagsYet": "Nie ma tagów",
|
||||
"onlyNonInlineAttachments": "Wyświetlane są tylko załączniki non-inline.",
|
||||
"remoteBlocked": "Aby chronić Twoją prywatność, Bichon zablokował zawartość zdalną w tej wiadomości.",
|
||||
"remoteShown": "Zawartość zdalna jest teraz widoczna.",
|
||||
"showLess": "pokaż mniej",
|
||||
"showMore": "pokaż więcej...",
|
||||
"showRemoteContent": "Pokaż zawartość zdalną",
|
||||
"subject": "Temat",
|
||||
"tags": "Tagi",
|
||||
"to": "Do",
|
||||
|
||||
@@ -100,6 +100,7 @@
|
||||
"auth": "Autenticação",
|
||||
"authType": "Tipo de Autenticação",
|
||||
"autoConfiguring": "Configurando Automaticamente...",
|
||||
"autoDownloadNewMailboxes": "Adicionar automaticamente novas pastas",
|
||||
"autoDownloadNewMailboxesDescription": "Adicionar automaticamente novas pastas à lista de download.",
|
||||
"beforeRelative": "Baixar apenas e-mails antigos",
|
||||
"beforeRelativeValue": "Baixar e-mails de {{value}} {{unit}} atrás",
|
||||
@@ -556,6 +557,7 @@
|
||||
"account": "Conta",
|
||||
"attachments": "Anexos",
|
||||
"bcc": "BCC",
|
||||
"blockRemoteAgain": "Bloquear novamente",
|
||||
"cc": "CC",
|
||||
"clickToDownload": "Clique para baixar",
|
||||
"date": "Data",
|
||||
@@ -575,8 +577,11 @@
|
||||
"noMessageSelected": "Nenhuma mensagem selecionada",
|
||||
"noTagsYet": "Nenhuma tag ainda",
|
||||
"onlyNonInlineAttachments": "Apenas anexos não incorporados são exibidos aqui.",
|
||||
"remoteBlocked": "Para proteger sua privacidade, o Bichon bloqueou o conteúdo remoto nesta mensagem.",
|
||||
"remoteShown": "Conteúdo remoto exibido.",
|
||||
"showLess": "mostrar menos",
|
||||
"showMore": "mostrar mais...",
|
||||
"showRemoteContent": "Mostrar conteúdo remoto",
|
||||
"subject": "Assunto",
|
||||
"tags": "Tags",
|
||||
"to": "Para",
|
||||
|
||||
@@ -100,6 +100,7 @@
|
||||
"auth": "Авторизация",
|
||||
"authType": "тип_авторизации",
|
||||
"autoConfiguring": "Автонастройка...",
|
||||
"autoDownloadNewMailboxes": "Автодобавление новых папок",
|
||||
"autoDownloadNewMailboxesDescription": "Автоматически добавлять новые папки в список загрузки.",
|
||||
"beforeRelative": "Скачать только старые письма",
|
||||
"beforeRelativeValue": "Скачать письма за {{value}} {{unit}} назад",
|
||||
@@ -556,6 +557,7 @@
|
||||
"account": "Аккаунт",
|
||||
"attachments": "Вложения",
|
||||
"bcc": "Скрытая",
|
||||
"blockRemoteAgain": "Заблокировать снова",
|
||||
"cc": "Копия",
|
||||
"clickToDownload": "Нажмите, чтобы скачать",
|
||||
"date": "Дата",
|
||||
@@ -575,8 +577,11 @@
|
||||
"noMessageSelected": "Сообщение не выбрано",
|
||||
"noTagsYet": "Тегов пока нет",
|
||||
"onlyNonInlineAttachments": "Здесь показаны только не встроенные вложения.",
|
||||
"remoteBlocked": "Для защиты вашей конфиденциальности Bichon заблокировал удаленный контент в этом сообщении.",
|
||||
"remoteShown": "Удаленный контент отображен.",
|
||||
"showLess": "свернуть",
|
||||
"showMore": "показать ещё...",
|
||||
"showRemoteContent": "Показать удаленный контент",
|
||||
"subject": "Тема",
|
||||
"tags": "Теги",
|
||||
"to": "Кому",
|
||||
|
||||
@@ -100,6 +100,7 @@
|
||||
"auth": "Auth",
|
||||
"authType": "auth_typ",
|
||||
"autoConfiguring": "Konfigurerar automatiskt...",
|
||||
"autoDownloadNewMailboxes": "Lägg till nya mappar automatiskt",
|
||||
"autoDownloadNewMailboxesDescription": "Lägg automatiskt till nya mappar i hämtningslistan.",
|
||||
"beforeRelative": "Ladda ner endast gamla e-postmeddelanden",
|
||||
"beforeRelativeValue": "Ladda ner e-post från {{value}} {{unit}} sedan",
|
||||
@@ -556,6 +557,7 @@
|
||||
"account": "Konto",
|
||||
"attachments": "Bilagor",
|
||||
"bcc": "Hemlig kopia",
|
||||
"blockRemoteAgain": "Blockera igen",
|
||||
"cc": "Kopia",
|
||||
"clickToDownload": "Klicka för att ladda ner",
|
||||
"date": "Datum",
|
||||
@@ -575,8 +577,11 @@
|
||||
"noMessageSelected": "Inget meddelande valt",
|
||||
"noTagsYet": "Inga etiketter än",
|
||||
"onlyNonInlineAttachments": "Endast icke-inbäddade bilagor visas här.",
|
||||
"remoteBlocked": "För att skydda din integritet har Bichon blockerat externt innehåll i detta meddelande.",
|
||||
"remoteShown": "Externt innehåll visas nu.",
|
||||
"showLess": "visa mindre",
|
||||
"showMore": "visa mer...",
|
||||
"showRemoteContent": "Visa externt innehåll",
|
||||
"subject": "Ämne",
|
||||
"tags": "Etiketter",
|
||||
"to": "Till",
|
||||
|
||||
@@ -100,6 +100,7 @@
|
||||
"auth": "驗證",
|
||||
"authType": "驗證類型",
|
||||
"autoConfiguring": "正在自動設定...",
|
||||
"autoDownloadNewMailboxes": "自動添加新郵件夾",
|
||||
"autoDownloadNewMailboxesDescription": "自動將新發現的郵件夾添加到下載列表中。",
|
||||
"beforeRelative": "僅下載舊郵件",
|
||||
"beforeRelativeValue": "下載 {{value}} {{unit}} 之前的郵件",
|
||||
@@ -556,6 +557,7 @@
|
||||
"account": "帳號",
|
||||
"attachments": "附件",
|
||||
"bcc": "密件副本 (BCC)",
|
||||
"blockRemoteAgain": "重新阻止",
|
||||
"cc": "副本 (CC)",
|
||||
"clickToDownload": "點擊下載",
|
||||
"date": "日期",
|
||||
@@ -575,8 +577,11 @@
|
||||
"noMessageSelected": "未選取任何訊息",
|
||||
"noTagsYet": "尚無標籤",
|
||||
"onlyNonInlineAttachments": "此處僅顯示非內嵌附件。",
|
||||
"remoteBlocked": "為了保護您的隱私,Bichon 已阻止此郵件中的遠端內容。",
|
||||
"remoteShown": "已顯示遠端內容。",
|
||||
"showLess": "顯示較少",
|
||||
"showMore": "顯示更多...",
|
||||
"showRemoteContent": "顯示遠端內容",
|
||||
"subject": "主旨",
|
||||
"tags": "標籤",
|
||||
"to": "收件人",
|
||||
|
||||
@@ -557,6 +557,7 @@
|
||||
"account": "账户",
|
||||
"attachments": "附件",
|
||||
"bcc": "密送",
|
||||
"blockRemoteAgain": "重新阻止",
|
||||
"cc": "抄送",
|
||||
"clickToDownload": "点击下载",
|
||||
"date": "日期",
|
||||
@@ -576,8 +577,11 @@
|
||||
"noMessageSelected": "未选择消息",
|
||||
"noTagsYet": "暂无标签",
|
||||
"onlyNonInlineAttachments": "此处仅显示非内联附件。",
|
||||
"remoteBlocked": "为了保护您的隐私,Bichon 已阻止此邮件中的远程内容。",
|
||||
"remoteShown": "已显示远程 content。",
|
||||
"showLess": "显示更少",
|
||||
"showMore": "显示更多...",
|
||||
"showRemoteContent": "显示远程内容",
|
||||
"subject": "主题",
|
||||
"tags": "标签",
|
||||
"to": "收件人",
|
||||
|
||||
1
web/src/test/setup.ts
Normal file
1
web/src/test/setup.ts
Normal file
@@ -0,0 +1 @@
|
||||
import '@testing-library/jest-dom/vitest'
|
||||
37
web/src/test/test-utils.tsx
Normal file
37
web/src/test/test-utils.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import { type ReactElement } from 'react'
|
||||
import { render, type RenderOptions } from '@testing-library/react'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { I18nextProvider } from 'react-i18next'
|
||||
import i18n from '@/i18n'
|
||||
|
||||
function createTestQueryClient() {
|
||||
return new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
interface WrapperProps {
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
function AllProviders({ children }: WrapperProps) {
|
||||
const queryClient = createTestQueryClient()
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<I18nextProvider i18n={i18n}>
|
||||
{children}
|
||||
</I18nextProvider>
|
||||
</QueryClientProvider>
|
||||
)
|
||||
}
|
||||
|
||||
function customRender(ui: ReactElement, options?: Omit<RenderOptions, 'wrapper'>) {
|
||||
return render(ui, { wrapper: AllProviders, ...options })
|
||||
}
|
||||
|
||||
export * from '@testing-library/react'
|
||||
export { customRender as render }
|
||||
export { createTestQueryClient }
|
||||
1
web/src/vite-env.d.ts
vendored
1
web/src/vite-env.d.ts
vendored
@@ -1 +1,2 @@
|
||||
/// <reference types="vite/client" />
|
||||
/// <reference types="vitest" />
|
||||
|
||||
@@ -60,7 +60,7 @@ export default {
|
||||
'5': 'hsl(var(--chart-5))'
|
||||
},
|
||||
sidebar: {
|
||||
DEFAULT: 'hsl(var(--sidebar-background))',
|
||||
DEFAULT: 'hsl(var(--sidebar))',
|
||||
foreground: 'hsl(var(--sidebar-foreground))',
|
||||
primary: 'hsl(var(--sidebar-primary))',
|
||||
'primary-foreground': 'hsl(var(--sidebar-primary-foreground))',
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/// <reference types="vitest" />
|
||||
import path from 'path'
|
||||
import { defineConfig } from 'vite'
|
||||
import { defineConfig } from 'vitest/config'
|
||||
import react from '@vitejs/plugin-react-swc'
|
||||
import { TanStackRouterVite } from '@tanstack/router-plugin/vite'
|
||||
|
||||
@@ -16,4 +17,10 @@ export default defineConfig({
|
||||
'@tabler/icons-react': '@tabler/icons-react/dist/esm/icons/index.mjs',
|
||||
},
|
||||
},
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'jsdom',
|
||||
setupFiles: './src/test/setup.ts',
|
||||
css: false,
|
||||
},
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user