Compare commits

33 Commits

Author SHA1 Message Date
rustmailer
d8b78b8010 update 2026-05-23 12:08:49 +08:00
rustmailer
36f3f19cdc fix(core): use email schema field for attachment hash lookup in cleanup_unused_content 2026-05-23 11:36:47 +08:00
rustmailer
9f3097df32 Merge pull request #259 from mmaudet/feat/search-by-server-timestamp
feat: filter and sort search-messages by a server-side timestamp
2026-05-23 11:04:54 +08:00
rustmailer
c075b9ef12 Merge pull request #258 from mmaudet/fix/self-heal-missing-content
fix: self-heal a missing content blob in download-message
2026-05-23 11:03:04 +08:00
rustmailer
21a7f7e9d5 Merge pull request #257 from mmaudet/fix/gc-blob-still-referenced
fix: prevent the dedup GC from deleting a still-referenced content blob
2026-05-23 10:58:34 +08:00
rustmailer
26c14fcaaf refactor(migrate): use searchable_segment_ids() instead of reader() for merge #261 2026-05-23 10:26:09 +08:00
Michel-Marie MAUDET
6873841ba4 fix(search): expose new SortBy variants via #[oai(rename)]
InternalDate and IngestAt were renamed for the wire with #[serde(rename)] only. SortBy derives poem_openapi::Enum, which does not honour serde attributes, so the REST deserializer exposed them under their Rust identifiers instead of the intended INTERNAL_DATE / INGEST_AT — inconsistent with the existing DATE/SIZE values and rejecting the documented names with HTTP 400. Add #[oai(rename = ...)] alongside the serde rename.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 23:14:01 +02:00
rustmailer
0dd81f599d update 2026-05-22 22:21:46 +08:00
Michel-Marie MAUDET
b3afc52a82 feat(search): filter and sort messages by server-side timestamps
POST /api/v1/search-messages previously filtered and sorted only on the
sender-controlled Date: header. Add support for two server-controlled
timestamps that are already indexed as FAST i64 fields:

- internal_date (IMAP INTERNALDATE)
- ingest_at (Bichon's archival time)

EmailSearchFilter gains internal_date_since/before and ingest_since/before
range bounds, mirroring the existing `since`/`before` Date: handling.
SortBy gains InternalDate and IngestAt variants (wire values INTERNAL_DATE
and INGEST_AT), mirroring the existing DATE/SIZE sort handling.

The envelope and attachment Tantivy schemas already declare these fields
as INDEXED | STORED | FAST, so no re-index or migration is required.
Attachments carry no IMAP INTERNALDATE, so the attachment search maps the
InternalDate sort to the attachment's own date field as a defined fallback.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 13:55:23 +02:00
Michel-Marie MAUDET
171a40d70f fix: self-heal missing content blob in download-message
The download-message REST endpoint returned 404 [30000] "Original email
content not found" whenever an indexed message's raw content blob was
missing from the blob store, leaving the message permanently
unrecoverable even though it still existed on the IMAP server.

Make the endpoint self-healing: when the content blob is absent, fetch
that one message on demand from the IMAP server via
UID FETCH <uid> (BODY.PEEK[]), repopulate the detached blob, and return
the content. The 404 is now only produced when the on-demand fetch
itself fails (mailbox gone, UID gone, connection failure, or the fetched
bytes no longer match the archived content_hash).

- Add ImapExecutor::fetch_single_message_body: examines the mailbox
  read-only and fetches one message by UID, reusing the existing
  BODY.PEEK[] fetch command.
- Add reattach_eml_content_self_healing / recover_message_blob in the
  envelope extractor: fast-path delegates to reattach_eml_content when
  the blob exists; otherwise recovers it via IMAP and re-stores the
  stripped EML + attachments through the existing blob queue.
- Make store::blob::get_reader async and route it through the
  self-healing path; update the single caller (download_message).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 13:46:40 +02:00
Michel-Marie MAUDET
895ea543a9 fix(core): commit/reload barrier before dedup GC reference count
cleanup_unused_content decides whether to delete a deduplicated blob by
running a Tantivy Count of envelopes referencing each content_hash. The
searcher it used reflected only the committed index state at the time it
was built, so an envelope that shared a content hash but was still
sitting uncommitted in the IndexWriter buffer (for example added by the
background ingest task before the delete operation acquired the writer
lock) was invisible to the count. The count read 0 and a
still-referenced blob was deleted, permanently 404ing that envelope's
download-message.

Pass the locked IndexWriter into cleanup_unused_content and fatal_commit
it immediately before creating the searcher. create_searcher already
reloads the reader, so the Count is now evaluated against a fully
committed, freshly-reloaded index state. The barrier is local to the GC
path and self-contained, independent of what the caller committed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 13:40:43 +02:00
rustmailer
6eca351994 update i18n 2026-05-21 23:52:26 +08:00
rustmailer
1f477eca65 bump to v1.2.0 2026-05-21 23:33:26 +08:00
rustmailer
a3cdc094e8 feat: Strip remote data from emails when viewed #54 2026-05-21 23:32:42 +08:00
rustmailer
95147a7824 bump to v1.1.3 2026-05-21 18:31:00 +08:00
rustmailer
b22811f78c fix: Transparent menu on iPhone #253 2026-05-21 18:29:03 +08:00
rustmailer
fd61d013a2 fix: Imported emails and UTF-8 folders missing #182 2026-05-21 17:57:43 +08:00
rustmailer
3a950e7591 fix: account name don't change when Update Account #248 2026-05-21 10:38:04 +08:00
rustmailer
f17820bfa8 fix: add missing attachment index cleanup logic 2026-05-21 08:45:30 +08:00
rustmailer
178b25d27d fix:After deleting an email, its attachment remains visible/active in the application #245 2026-05-20 17:40:12 +08:00
rustmailer
d160ca75f5 fix: migration link doesn't exist #244 2026-05-20 17:19:31 +08:00
rustmailer
04136a4ae2 bump to v1.1.2 2026-05-19 23:58:52 +08:00
rustmailer
1d6f5d9a22 Merge pull request #243 from tremor021/smallfix
Fix small typo in store.rs
2026-05-19 23:56:11 +08:00
rustmailer
105a6d9b15 Update dedup.rs 2026-05-19 23:50:46 +08:00
rustmailer
df440c8441 Update README.md 2026-05-19 23:33:44 +08:00
Slaviša Arežina
4116a59b79 Merge branch 'main' into smallfix 2026-05-19 17:28:15 +02:00
rustmailer
609eee1b84 show storage and index usage to everyone 2026-05-19 23:25:36 +08:00
tremor021
79b9f07888 fix small typo in store.rs 2026-05-19 17:18:10 +02:00
rustmailer
ba28369202 update 2026-05-19 13:58:20 +08:00
rustmailer
ff64b66f79 fix: Migration to v1.0 panics with index out of bounds: the len is 0 but the index is 0 #234 2026-05-19 12:12:37 +08:00
rustmailer
a4f8e674c3 Update Cargo.lock 2026-05-18 20:46:31 +08:00
rustmailer
dde6b990da bump to v1.1.0 2026-05-18 20:46:19 +08:00
rustmailer
6b1f843bd5 Merge pull request #237 from rustmailer/fix/cli-mbox-memory
fix: bichon-cli OOMs on import #233
2026-05-18 20:27:03 +08:00
78 changed files with 4131 additions and 588 deletions

26
Cargo.lock generated
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

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

View File

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

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

View File

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

View File

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

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

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

View File

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

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

View File

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

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

View File

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

View File

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

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

View File

@@ -16,7 +16,6 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
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) => {

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

View File

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

View File

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

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

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

View File

@@ -16,7 +16,6 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
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') })

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

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

View File

@@ -16,7 +16,6 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
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

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

View File

@@ -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": "إلى",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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": "宛先",

View File

@@ -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": "받는 사람",

View File

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

View File

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

View File

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

View File

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

View File

@@ -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": "Кому",

View File

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

View File

@@ -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": "收件人",

View File

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

@@ -0,0 +1 @@
import '@testing-library/jest-dom/vitest'

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

View File

@@ -1 +1,2 @@
/// <reference types="vite/client" />
/// <reference types="vitest" />

View File

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

View File

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