12 Commits
1.3.0 ... 1.4.0

Author SHA1 Message Date
rustmailer
86869ac848 Update release.yml 2026-05-26 20:11:49 +08:00
rustmailer
38453accc6 Update release.yml 2026-05-26 20:07:40 +08:00
rustmailer
a60b2c7dc4 Update release.yml 2026-05-26 20:02:55 +08:00
rustmailer
8e46c7a162 fix: use valid IMAP UID SEARCH instead of BEFORE in UID FETCH for incremental sync 2026-05-26 19:42:26 +08:00
rustmailer
1a615e1c45 bump to v1.4.0 2026-05-26 15:27:34 +08:00
rustmailer
83dd9cdd6b perf: optimize IMAP account fetch flow 2026-05-26 15:23:34 +08:00
rustmailer
f30cd66e00 feat: remove folder limit 2026-05-26 15:22:14 +08:00
rustmailer
4bd714a670 chore: remove custom global allocator 2026-05-26 15:20:59 +08:00
rustmailer
c0a63a1e3c feat: enhance autoconfig detection 2026-05-26 15:19:39 +08:00
rustmailer
04a022e850 add features endpoint 2026-05-24 20:31:58 +08:00
rustmailer
f9c2fc77ff feat(core): wire up attachment text extraction in IMAP sync pipeline 2026-05-24 19:52:28 +08:00
rustmailer
ec3e842bbb chore: add trace logging for duplicate email diagnosis #214 2026-05-24 17:27:01 +08:00
56 changed files with 1194 additions and 534 deletions

3
.gitignore vendored
View File

@@ -2,4 +2,5 @@
.vscode
.idea
config.toml
node_modules
node_modules
dedup_report.txt

34
Cargo.lock generated
View File

@@ -293,7 +293,7 @@ checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]]
name = "bichon-admin"
version = "1.3.0"
version = "1.4.0"
dependencies = [
"bichon-core",
"console",
@@ -301,7 +301,6 @@ dependencies = [
"indicatif",
"itertools",
"memdb",
"mimalloc",
"native_db",
"native_model",
"serde",
@@ -312,7 +311,7 @@ dependencies = [
[[package]]
name = "bichon-cli"
version = "1.3.0"
version = "1.4.0"
dependencies = [
"base64 0.22.1",
"bichon-core",
@@ -338,7 +337,7 @@ dependencies = [
[[package]]
name = "bichon-core"
version = "1.3.0"
version = "1.4.0"
dependencies = [
"async-imap",
"base64 0.22.1",
@@ -397,7 +396,7 @@ dependencies = [
[[package]]
name = "bichon-server"
version = "1.3.0"
version = "1.4.0"
dependencies = [
"bichon-core",
"bichon-smtp",
@@ -405,7 +404,6 @@ dependencies = [
"email_address",
"governor",
"http",
"mimalloc",
"poem",
"poem-derive",
"poem-openapi",
@@ -422,7 +420,7 @@ dependencies = [
[[package]]
name = "bichon-smtp"
version = "1.3.0"
version = "1.4.0"
dependencies = [
"base64 0.22.1",
"bichon-core",
@@ -1791,9 +1789,9 @@ checksum = "e9025058dae765dee5070ec375f591e2ba14638c63feff74f13805a72e523163"
[[package]]
name = "http"
version = "1.4.0"
version = "1.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a"
checksum = "8be7462df143984c4598a256ef469b251d7d7f9e271135073e78fc535414f3d0"
dependencies = [
"bytes 1.11.1",
"itoa",
@@ -2264,15 +2262,6 @@ version = "0.2.185"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52ff2c0fe9bc6cb6b14a0592c2ff4fa9ceb83eea9db979b0487cd054946a2b8f"
[[package]]
name = "libmimalloc-sys"
version = "0.1.49"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6a45a52f43e1c16f667ccfe4dd8c85b7f7c204fd5e3bf46c5b0db9a5c3c0b8e9"
dependencies = [
"cc",
]
[[package]]
name = "linux-raw-sys"
version = "0.12.1"
@@ -2477,15 +2466,6 @@ dependencies = [
"libc",
]
[[package]]
name = "mimalloc"
version = "0.1.52"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2d4139bb28d14ad1facf21d5eb8825051b326e172d216b39f6d31df53cc97862"
dependencies = [
"libmimalloc-sys",
]
[[package]]
name = "mime"
version = "0.3.17"

View File

@@ -12,13 +12,12 @@ members = [
resolver = "2"
[workspace.package]
version = "1.3.0"
version = "1.4.0"
edition = "2021"
[workspace.dependencies]
chrono = "0.4.44"
clap = { version = "4.6.1", features = ["derive", "env"] }
mimalloc = "0.1.52"
memdb = { path = "crates/memdb" }
itertools = "0.14.0"
ring = { version = "0.17.14", features = ["std"] }
@@ -39,7 +38,7 @@ reqwest = { version = "0.12.24", default-features = false, features = [
"socks",
] }
tokio-socks = "0.5.2"
http = "1.4.0"
http = "1.4.1"
regex = "1.12.3"
email_address = "0.2.9"
futures = "0.3.32"

View File

@@ -584,8 +584,8 @@ No. Bichon is an **archiver**, not an email client. The optional SMTP server **r
### What hardware does Bichon need?
- **Minimal:** 1 CPU core, 512 MB RAM
- **Recommended (100+ accounts, 200+ GB):** 4+ cores, 2+ GB RAM
- **Recommended:** 4+ CPU cores, 2+ GB RAM (sufficient for 10+ accounts and 200+ GB of archived data)
- Filesystem: use a mainstream Linux filesystem such as **ext4** or **XFS**; avoid network / virtual filesystems (NFS, VirtIO-FS) for all data directories
- Indices benefit from SSD storage; blob storage can use HDD
### How do I reset the admin password?
@@ -656,7 +656,6 @@ Feel free to open an [Issue](https://github.com/rustmailer/bichon/issues) or joi
| **Frontend** | React 18, TypeScript, Vite 6, ShadCN UI, TanStack Router/Query/Table |
| **Charts** | Recharts |
| **i18n** | i18next (18 languages) |
| **Allocator** | mimalloc |
| **Container** | Ubuntu 24.04, Docker |
## License

View File

@@ -17,5 +17,4 @@ serde_json.workspace = true
itertools.workspace = true
snafu.workspace = true
memdb.workspace = true
mimalloc = "0.1.50"
memdb.workspace = true

View File

@@ -18,7 +18,6 @@
use console::style;
use dialoguer::{theme::ColorfulTheme, Select};
use mimalloc::MiMalloc;
use crate::{migrate::handle_migration, reset::handle_reset_password};
@@ -26,8 +25,6 @@ pub mod meta;
pub mod migrate;
pub mod reset;
#[global_allocator]
static GLOBAL: MiMalloc = MiMalloc;
fn main() {
run_interactive();

View File

@@ -117,7 +117,6 @@ pub struct AccountV3 {
pub capabilities: Option<Vec<String>>,
pub date_since: Option<DateSince>,
pub date_before: Option<RelativeDate>,
pub folder_limit: Option<u32>,
pub sync_folders: Option<Vec<String>>,
pub account_type: AccountType,
pub sync_interval_min: Option<i64>,
@@ -193,7 +192,7 @@ impl From<AccountV3> for AccountV2 {
name: value.name,
capabilities: value.capabilities,
date_since: value.date_since,
folder_limit: value.folder_limit,
folder_limit: None,
sync_folders: value.sync_folders,
account_type: value.account_type,
sync_interval_min: value.sync_interval_min,
@@ -217,7 +216,6 @@ impl From<AccountV2> for AccountV3 {
name: value.name,
capabilities: value.capabilities,
date_since: value.date_since,
folder_limit: value.folder_limit,
sync_folders: value.sync_folders,
account_type: value.account_type,
sync_interval_min: value.sync_interval_min,
@@ -246,7 +244,6 @@ impl From<AccountV3> for AccountModel {
capabilities: value.capabilities,
date_since: value.date_since,
date_before: value.date_before,
folder_limit: value.folder_limit,
download_folders: value.sync_folders,
account_type: value.account_type,
download_interval_min: value.sync_interval_min,
@@ -661,6 +658,7 @@ impl From<MailBox> for bichon_core::cache::imap::mailbox::MailBox {
unseen: value.unseen,
uid_next: value.uid_next,
uid_validity: value.uid_validity,
highest_uid: None,
}
}
}

View File

@@ -80,7 +80,6 @@ pub struct Account {
pub capabilities: Option<Vec<String>>,
pub date_since: Option<DateSince>,
pub date_before: Option<RelativeDate>,
pub folder_limit: Option<u32>,
pub download_folders: Option<Vec<String>>,
pub account_type: AccountType,
pub download_interval_min: Option<i64>,
@@ -125,7 +124,6 @@ impl Account {
created_at: utc_now!(),
updated_at: utc_now!(),
use_proxy: request.use_proxy,
folder_limit: request.folder_limit,
use_dangerous: request.use_dangerous,
pgp_key: request.pgp_key,
created_by: user_id,
@@ -367,20 +365,10 @@ impl Account {
}
}
if let Some(folder_limit) = request.folder_limit {
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;
}
}
if matches!(old.account_type, AccountType::IMAP) {
if let Some(imap) = &request.imap {
if let Some(current_imap) = &mut new.imap {

View File

@@ -41,8 +41,6 @@ pub struct AccountCreateRequest {
pub date_since: Option<DateSince>,
pub date_before: Option<RelativeDate>,
pub account_type: AccountType,
#[cfg_attr(feature = "web-api", oai(validator(minimum(value = "100"))))]
pub folder_limit: Option<u32>,
#[cfg_attr(feature = "web-api", oai(validator(minimum(value = "10"))))]
pub download_interval_min: Option<i64>,
#[cfg_attr(
@@ -145,12 +143,6 @@ pub struct AccountUpdateRequest {
pub date_since: Option<DateSince>,
pub date_before: Option<RelativeDate>,
pub clear_date_range: Option<bool>,
/// Max emails to sync for this folder.
/// If not set, sync all emails.
/// otherwise sync up to `n` most recent emails (min 10).
#[cfg_attr(feature = "web-api", oai(validator(minimum(value = "100"))))]
pub folder_limit: Option<u32>,
pub clear_folder_limit: Option<bool>,
/// Configuration for selective folder (mailbox/label) synchronization
///
/// - For IMAP/SMTP accounts:
@@ -205,13 +197,6 @@ impl AccountUpdateRequest {
));
}
if self.clear_folder_limit == Some(true) && self.folder_limit.is_some() {
return Err(raise_error!(
"clear_folder_limit cannot be combined with folder_limit".into(),
ErrorCode::InvalidParameter
));
}
if self.clear_date_range == Some(true)
&& (self.date_since.is_some() || self.date_before.is_some())
{

View File

@@ -364,4 +364,43 @@ mod test {
};
assert!(e.validate().is_err());
}
// ── Sliding window tests ──────────────────────────────────────
#[test]
fn relative_date_calculate_returns_valid_format() {
let r = RelativeDate {
unit: Unit::Years,
value: 1,
};
let date_str = r.calculate_date().unwrap();
// Expect format like "26-May-2025"
assert!(date_str.len() > 5);
assert!(date_str.contains('-'));
}
#[test]
fn relative_date_one_year_ago_is_before_now() {
let r = RelativeDate {
unit: Unit::Years,
value: 1,
};
let date_str = r.calculate_date().unwrap();
let parsed = chrono::NaiveDate::parse_from_str(&date_str, "%d-%b-%Y").unwrap();
let today = chrono::Local::now().date_naive();
assert!(parsed < today, "1 year ago ({parsed}) should be before today ({today})");
}
#[test]
fn relative_date_one_day_ago_is_yesterday() {
let r = RelativeDate {
unit: Unit::Days,
value: 1,
};
let date_str = r.calculate_date().unwrap();
let parsed = chrono::NaiveDate::parse_from_str(&date_str, "%d-%b-%Y").unwrap();
let today = chrono::Local::now().date_naive();
let yesterday = today - chrono::Duration::days(1);
assert_eq!(parsed, yesterday, "1 day ago should be yesterday");
}
}

View File

@@ -40,7 +40,6 @@ pub struct AccountResp {
pub capabilities: Option<Vec<String>>,
pub date_since: Option<DateSince>,
pub date_before: Option<RelativeDate>,
pub folder_limit: Option<u32>,
pub download_folders: Option<Vec<String>>,
pub account_type: AccountType,
pub download_interval_min: Option<i64>,
@@ -73,7 +72,6 @@ impl AccountResp {
capabilities: account.capabilities,
date_since: account.date_since,
date_before: account.date_before,
folder_limit: account.folder_limit,
download_folders: account.download_folders,
account_type: account.account_type,
download_interval_min: account.download_interval_min,

View File

@@ -45,6 +45,10 @@ pub struct IncomingServer {
#[serde(rename = "socketType")]
pub socket_type: String,
pub username: String,
/// Authentication method from the XML, e.g. "OAuth2", "password-cleartext",
/// "password-encrypted", "GSSAPI", "NTLM". Absent in DNS SRV fallback.
#[serde(default)]
pub authentication: String,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
@@ -137,6 +141,7 @@ async fn lookup_srv(domain: &str) -> Option<MailConfig> {
port: imap_port,
socket_type: "SSL".to_string(),
username: "%EMAILADDRESS%".to_string(),
authentication: String::new(),
}],
outgoing: vec![OutgoingServer {
protocol: "smtp".to_string(),
@@ -153,30 +158,37 @@ async fn lookup_srv(domain: &str) -> Option<MailConfig> {
// ---------------------------------------------------------------------------
/// Discover mail server configuration for a domain using the Thunderbird
/// autoconfig protocol (ISPDB) and DNS SRV fallback.
/// autoconfig protocol (ISPDB), DNS SRV, MX fallback, and finally guessing.
///
/// Probe order:
/// 1. `https://autoconfig.{domain}/mail/config-v1.1.xml`
/// 2. `https://{domain}/.well-known/autoconfig/mail/config-v1.1.xml`
/// 3. DNS SRV records (`_imaps._tcp` / `_submission._tcp`)
/// 4. Thunderbird central ISPDB (`https://autoconfig.thunderbird.net/v1.1/{domain}`)
/// 2. `http://autoconfig.{domain}/mail/config-v1.1.xml`
/// 3. `https://{domain}/.well-known/autoconfig/mail/config-v1.1.xml`
/// 4. `http://{domain}/.well-known/autoconfig/mail/config-v1.1.xml`
/// 5. DNS SRV records (`_imaps._tcp` / `_submission._tcp`)
/// 6. Thunderbird central ISPDB (`https://autoconfig.thunderbird.net/v1.1/{domain}`)
/// 7. MX lookup → ISPDB for MX domain
/// 8. MX lookup → ISP autoconfig for MX domain
/// 9. GuessConfig — probe common hostnames + ports
pub async fn fetch(domain: &str) -> BichonResult<MailConfig> {
let client = Client::builder()
.timeout(std::time::Duration::from_secs(10))
.build()
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
// 1. Try autoconfig subdomain
if let Some(config) = fetch_xml(
&client,
&format!("https://autoconfig.{domain}/mail/config-v1.1.xml"),
)
.await
// ── ISP autoconfig (HTTPS, then HTTP) ──────────────────────────
if let Some(config) =
fetch_xml(&client, &format!("https://autoconfig.{domain}/mail/config-v1.1.xml")).await
{
return Ok(config);
}
if let Some(config) =
fetch_xml(&client, &format!("http://autoconfig.{domain}/mail/config-v1.1.xml")).await
{
return Ok(config);
}
// 2. Try well-known path
// ── Well-known path (HTTPS, then HTTP) ─────────────────────────
if let Some(config) = fetch_xml(
&client,
&format!("https://{domain}/.well-known/autoconfig/mail/config-v1.1.xml"),
@@ -185,28 +197,100 @@ pub async fn fetch(domain: &str) -> BichonResult<MailConfig> {
{
return Ok(config);
}
if let Some(config) = fetch_xml(
&client,
&format!("http://{domain}/.well-known/autoconfig/mail/config-v1.1.xml"),
)
.await
{
return Ok(config);
}
// 3. Try DNS SRV records
// ── DNS SRV records ────────────────────────────────────────────
if let Some(config) = lookup_srv(domain).await {
return Ok(config);
}
// 4. Fall back to Thunderbird central database
if let Some(config) = fetch_xml(
&client,
&format!("https://autoconfig.thunderbird.net/v1.1/{domain}"),
)
.await
// ── Thunderbird central ISPDB ──────────────────────────────────
if let Some(config) =
fetch_xml(&client, &format!("https://autoconfig.thunderbird.net/v1.1/{domain}")).await
{
return Ok(config);
}
// ── MX fallback ────────────────────────────────────────────────
if let Some(config) = fetch_for_mx(&client, domain).await {
return Ok(config);
}
// ── GuessConfig ────────────────────────────────────────────────
if let Some(config) = crate::autoconfig::guess::guess_config(domain).await {
return Ok(config);
}
Err(raise_error!(
format!("No autoconfig found for domain: {domain}"),
ErrorCode::InternalError
))
}
/// DNS MX lookup → retry ISPDB and ISP autoconfig for the MX domain.
///
/// Many self-hosted domains have their MX pointed at Google, Microsoft, etc.
/// The MX domain's ISPDB entry covers the original domain.
async fn fetch_for_mx(client: &Client, domain: &str) -> Option<MailConfig> {
let mx_domain = lookup_mx_domain(domain).await?;
if mx_domain == domain.to_ascii_lowercase() {
return None; // same domain, already tried above
}
// Try ISPDB for the MX domain
if let Some(config) =
fetch_xml(client, &format!("https://autoconfig.thunderbird.net/v1.1/{mx_domain}")).await
{
return Some(config);
}
// Try ISP autoconfig for the MX domain (HTTPS then HTTP)
if let Some(config) =
fetch_xml(client, &format!("https://autoconfig.{mx_domain}/mail/config-v1.1.xml")).await
{
return Some(config);
}
if let Some(config) =
fetch_xml(client, &format!("http://autoconfig.{mx_domain}/mail/config-v1.1.xml")).await
{
return Some(config);
}
None
}
/// DNS MX lookup → extract the second-level domain of the first MX hostname.
async fn lookup_mx_domain(domain: &str) -> Option<String> {
let resolver = TokioResolver::builder(TokioConnectionProvider::default())
.ok()?
.build();
let lookup = resolver.mx_lookup(domain).await.ok()?;
let record = lookup.iter().next()?;
let mx_host = record.to_string().trim_end_matches('.').to_string();
// Extract a reasonable base domain from the MX hostname.
// E.g., "aspmx.l.google.com" → "google.com"
// "company.mail.protection.outlook.com" → "outlook.com"
extract_base_domain(&mx_host)
}
/// Extract the top two labels from a hostname as a rough base domain.
fn extract_base_domain(host: &str) -> Option<String> {
let parts: Vec<&str> = host.split('.').collect();
if parts.len() >= 2 {
Some(parts[parts.len() - 2..].join("."))
} else {
None
}
}
#[cfg(test)]
mod tests {
use super::*;

View File

@@ -0,0 +1,105 @@
//
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use crate::account::entity::Encryption;
use crate::autoconfig::client::{IncomingServer, MailConfig};
use crate::imap::client::Client;
use tracing::{debug, info};
/// A single host:port:encryption combination to probe.
struct Guess {
hostname: String,
port: u16,
encryption: Encryption,
socket_type: &'static str,
}
/// Generate candidates in the same order Thunderbird uses:
/// 1. imap.{domain} — most common
/// 2. mail.{domain} — fallback
/// 3. {domain} — bare domain (rare)
fn make_guesses(domain: &str) -> Vec<Guess> {
let hosts = [
format!("imap.{domain}"),
format!("mail.{domain}"),
domain.to_string(),
];
let mut guesses = Vec::with_capacity(hosts.len() * 2);
for host in &hosts {
guesses.push(Guess {
hostname: host.clone(),
port: 993,
encryption: Encryption::Ssl,
socket_type: "SSL",
});
guesses.push(Guess {
hostname: host.clone(),
port: 143,
encryption: Encryption::StartTls,
socket_type: "STARTTLS",
});
}
guesses
}
/// Try to open a connection, read the IMAP banner, and close.
/// Returns `true` if the server responds with an IMAP greeting.
async fn probe(hostname: &str, port: u16, encryption: &Encryption) -> bool {
match Client::connection(hostname, encryption, port, None, true).await {
Ok(_) => {
debug!("GuessConfig probe succeeded: {hostname}:{port} ({encryption:?})");
true
}
Err(e) => {
debug!("GuessConfig probe failed for {hostname}:{port}: {e:?}");
false
}
}
}
/// Thunderbird-style guessing: try common hostnames and ports, probing
/// each with a real TCP connection.
///
/// Returns the first working `MailConfig`, or `None` if nothing works.
pub async fn guess_config(domain: &str) -> Option<MailConfig> {
let guesses = make_guesses(domain);
info!("GuessConfig: trying {} candidates for {domain}", guesses.len());
for g in &guesses {
if probe(&g.hostname, g.port, &g.encryption).await {
info!(
"GuessConfig: found working IMAP at {}:{} ({})",
g.hostname, g.port, g.socket_type
);
return Some(MailConfig {
incoming: vec![IncomingServer {
protocol: "imap".to_string(),
hostname: g.hostname.clone(),
port: g.port,
socket_type: g.socket_type.to_string(),
username: "%EMAILADDRESS%".to_string(),
authentication: String::new(),
}],
outgoing: vec![],
});
}
}
None
}

View File

@@ -19,6 +19,7 @@
use crate::account::entity::Encryption;
use crate::autoconfig::client::{self, MailConfig};
use crate::autoconfig::entity::{MailServerConfig, ServerConfig};
use crate::autoconfig::oauth2_providers::lookup_oauth2;
use crate::autoconfig::CachedMailSettings;
use crate::error::code::ErrorCode;
use crate::error::BichonResult;
@@ -54,9 +55,17 @@ pub(crate) fn mail_config_to_server_config(config: &MailConfig) -> Option<MailSe
}
};
// Detect OAuth2 support: the XML <authentication> field and a known
// hostname → issuer mapping determine whether the provider supports OAuth2.
let oauth2 = if imap.authentication.eq_ignore_ascii_case("OAuth2") {
lookup_oauth2(&imap.hostname)
} else {
None
};
Some(MailServerConfig {
imap: ServerConfig::new(imap.hostname.clone(), port, encryption),
oauth2: None,
oauth2,
})
}

View File

@@ -24,7 +24,9 @@ use serde::{Deserialize, Serialize};
pub mod client;
pub mod entity;
pub mod guess;
pub mod load;
mod oauth2_providers;
#[cfg(test)]
mod tests;

View File

@@ -0,0 +1,151 @@
//
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use crate::autoconfig::entity::OAuth2Config;
/// Per-provider OAuth2 metadata, mirroring Thunderbird's `OAuth2Providers.sys.mjs`.
///
/// Each entry maps one or more IMAP hostname suffixes to a well-known OIDC issuer
/// and the IMAP-specific OAuth2 scopes.
struct Provider {
/// Suffixes matched case-insensitively against the end of the IMAP hostname.
host_suffixes: &'static [&'static str],
/// The OIDC issuer URL used by the provider.
issuer: &'static str,
/// OAuth2 scope(s) required for IMAP access.
scopes: &'static [&'static str],
}
const PROVIDERS: &[Provider] = &[
// Google
Provider {
host_suffixes: &["imap.gmail.com", ".gmail.com", ".googlemail.com"],
issuer: "https://accounts.google.com",
scopes: &["https://mail.google.com/"],
},
// Microsoft (Outlook / Office 365 / Hotmail / Live)
Provider {
host_suffixes: &[
"outlook.office365.com",
".outlook.com",
".hotmail.com",
".live.com",
".office365.com",
],
issuer: "https://login.microsoftonline.com/common/v2.0",
scopes: &[
"https://outlook.office365.com/IMAP.AccessAsUser.All",
"offline_access",
],
},
// Yahoo / AOL / ATT / Verizon
Provider {
host_suffixes: &[
"imap.mail.yahoo.com",
".yahoo.com",
".yahoodns.net",
".aol.com",
"imap.aol.com",
],
issuer: "https://login.yahoo.com",
scopes: &["mail-w"],
},
// Yandex
Provider {
host_suffixes: &["imap.yandex.ru", "imap.yandex.com", ".yandex.ru"],
issuer: "https://oauth.yandex.com",
scopes: &["imap:all"],
},
// Mail.ru
Provider {
host_suffixes: &["imap.mail.ru", ".mail.ru", ".bk.ru", ".list.ru", ".inbox.ru"],
issuer: "https://o2.mail.ru",
scopes: &["imap"],
},
// Fastmail
Provider {
host_suffixes: &["imap.fastmail.com", ".fastmail.com"],
issuer: "https://www.fastmail.com",
scopes: &[
"https://www.fastmail.com/dev/imap",
"offline_access",
],
},
// Comcast
Provider {
host_suffixes: &["imap.comcast.net", ".comcast.net"],
issuer: "https://oauth.xfinity.com",
scopes: &["https://email.comcast.net/"],
},
];
/// Try to find an OAuth2 provider that matches the given IMAP hostname.
///
/// Matching is case-insensitive and done by suffix: a hostname "imap.gmail.com"
/// matches the suffix ".gmail.com".
pub fn lookup_oauth2(hostname: &str) -> Option<OAuth2Config> {
let host = hostname.to_ascii_lowercase();
for provider in PROVIDERS {
if provider
.host_suffixes
.iter()
.any(|suffix| host.ends_with(&suffix.to_ascii_lowercase()))
{
return Some(OAuth2Config {
issuer: provider.issuer.to_string(),
scope: provider.scopes.iter().map(|s| s.to_string()).collect(),
auth_url: String::new(),
token_url: String::new(),
});
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_known_providers() {
let cases = [
("imap.gmail.com", Some("https://accounts.google.com")),
("imap.gmail.com", Some("https://accounts.google.com")),
("outlook.office365.com", Some("https://login.microsoftonline.com/common/v2.0")),
("imap.mail.yahoo.com", Some("https://login.yahoo.com")),
("imap.aol.com", Some("https://login.yahoo.com")),
("imap.yandex.ru", Some("https://oauth.yandex.com")),
("imap.mail.ru", Some("https://o2.mail.ru")),
("imap.fastmail.com", Some("https://www.fastmail.com")),
("imap.comcast.net", Some("https://oauth.xfinity.com")),
];
for (hostname, expected_issuer) in &cases {
let result = lookup_oauth2(hostname);
assert_eq!(
result.map(|c| c.issuer),
expected_issuer.map(|s| s.to_string()),
"failed for hostname: {hostname}"
);
}
}
#[test]
fn test_unknown_provider() {
assert!(lookup_oauth2("mail.my-company.example").is_none());
}
}

View File

@@ -195,6 +195,7 @@ fn make_imap_server(host: &str, port: u16, socket_type: &str) -> IncomingServer
port,
socket_type: socket_type.to_string(),
username: "%EMAILADDRESS%".to_string(),
authentication: String::new(),
}
}
@@ -241,6 +242,7 @@ fn convert_no_imap_only_pop3() {
port: 995,
socket_type: "SSL".to_string(),
username: "%EMAILADDRESS%".to_string(),
authentication: String::new(),
}],
outgoing: vec![],
};
@@ -266,6 +268,7 @@ fn convert_picks_imap_over_pop3() {
port: 995,
socket_type: "SSL".to_string(),
username: "%EMAILADDRESS%".to_string(),
authentication: String::new(),
},
make_imap_server("imap.example.com", 993, "SSL"),
],
@@ -284,6 +287,7 @@ fn convert_imaps_protocol_variant() {
port: 993,
socket_type: "SSL".to_string(),
username: "%EMAILADDRESS%".to_string(),
authentication: String::new(),
}],
outgoing: vec![],
};
@@ -300,9 +304,65 @@ fn convert_case_insensitive_protocol() {
port: 143,
socket_type: "STARTTLS".to_string(),
username: "%EMAILADDRESS%".to_string(),
authentication: String::new(),
}],
outgoing: vec![],
};
let result = mail_config_to_server_config(&config).expect("should recognize 'IMAP'");
assert_eq!(result.imap.host, "imap.example.com");
}
#[test]
fn convert_gmail_oauth2() {
let config = MailConfig {
incoming: vec![IncomingServer {
protocol: "imap".to_string(),
hostname: "imap.gmail.com".to_string(),
port: 993,
socket_type: "SSL".to_string(),
username: "%EMAILADDRESS%".to_string(),
authentication: "OAuth2".to_string(),
}],
outgoing: vec![],
};
let result = mail_config_to_server_config(&config).expect("should convert");
let oauth2 = result.oauth2.expect("Gmail should have OAuth2");
assert_eq!(oauth2.issuer, "https://accounts.google.com");
assert!(oauth2.scope.contains(&"https://mail.google.com/".to_string()));
}
#[test]
fn convert_outlook_oauth2() {
let config = MailConfig {
incoming: vec![IncomingServer {
protocol: "imap".to_string(),
hostname: "outlook.office365.com".to_string(),
port: 993,
socket_type: "SSL".to_string(),
username: "%EMAILADDRESS%".to_string(),
authentication: "OAuth2".to_string(),
}],
outgoing: vec![],
};
let result = mail_config_to_server_config(&config).expect("should convert");
let oauth2 = result.oauth2.expect("Outlook should have OAuth2");
assert!(oauth2.issuer.contains("microsoftonline"));
}
#[test]
fn convert_unknown_host_no_oauth2() {
// OAuth2 auth flag on an unknown hostname → no OAuth2 returned
let config = MailConfig {
incoming: vec![IncomingServer {
protocol: "imap".to_string(),
hostname: "mail.random-isp.example".to_string(),
port: 993,
socket_type: "SSL".to_string(),
username: "%EMAILADDRESS%".to_string(),
authentication: "OAuth2".to_string(),
}],
outgoing: vec![],
};
let result = mail_config_to_server_config(&config).expect("should convert");
assert!(result.oauth2.is_none(), "unknown hostname → no OAuth2 mapping");
}

View File

@@ -77,10 +77,7 @@ pub async fn decide_next_download_task(
}
}
fn should_trigger_next_download(
last_trigger_at: i64,
sync_interval_min: i64,
) -> bool {
fn should_trigger_next_download(last_trigger_at: i64, sync_interval_min: i64) -> bool {
let now = utc_now!();
now - last_trigger_at > (sync_interval_min * 60 * 1000)
}
@@ -107,7 +104,10 @@ fn should_trigger_scheduled(schedule_str: &str, last_trigger_at: i64) -> bool {
};
let last_dt: DateTime<Local> = last_utc.with_timezone(&Local);
let now = Local::now();
schedule.after(&last_dt).next().map_or(false, |next| next <= now)
schedule
.after(&last_dt)
.next()
.map_or(false, |next| next <= now)
}
#[cfg(test)]
@@ -153,13 +153,4 @@ mod test {
let last_trigger = now.timestamp_millis() - 61 * 60 * 1000;
assert!(should_trigger_scheduled("0 0 * * * *", last_trigger));
}
#[test]
fn cron_every_hour_no_trigger_if_recent() {
// "0 0 * * * *" = every hour at minute 0, second 0
// last_trigger was just 1 minute ago → should NOT trigger (except at :00/:01 boundary)
let now = Local::now();
let last_trigger = now.timestamp_millis() - 60_000;
assert!(!should_trigger_scheduled("0 0 * * * *", last_trigger));
}
}

View File

@@ -32,7 +32,9 @@ use crate::{
SEMAPHORE,
},
error::{code::ErrorCode, BichonResult},
imap::executor::ImapExecutor,
imap::executor::{
generate_uid_sequence_hashset, ImapExecutor, DEFAULT_BATCH_SIZE,
},
store::tantivy::envelope::ENVELOPE_MANAGER,
},
};
@@ -40,7 +42,6 @@ use std::time::Instant;
use tokio_util::sync::CancellationToken;
use tracing::{debug, error, info, warn};
pub const DEFAULT_BATCH_SIZE: u32 = 30;
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum FetchDirection {
@@ -54,7 +55,7 @@ pub async fn fetch_and_save_by_date(
mailbox: &MailBox,
direction: FetchDirection,
token: CancellationToken,
) -> BichonResult<()> {
) -> BichonResult<Option<u32>> {
let account_id = account.id;
let mut session = match ImapExecutor::create_connection(account_id).await {
Ok(session) => session,
@@ -108,32 +109,18 @@ pub async fn fetch_and_save_by_date(
FolderStatus::Success,
None,
)?;
return Ok(());
return Ok(None);
}
let folder_limit = account.folder_limit;
// sort small -> bigger
let mut uid_vec: Vec<u32> = uid_list.into_iter().collect();
uid_vec.sort();
if let Some(limit) = folder_limit {
let limit = limit.max(100) as usize;
if len > limit {
uid_vec = match direction {
FetchDirection::Since => uid_vec.split_off(len - limit),
FetchDirection::Before => {
uid_vec.truncate(limit);
uid_vec
}
};
}
}
let max_uid = uid_vec.last().copied();
let planned = uid_vec.len() as u64;
let uid_batches = generate_uid_sequence_hashset(
uid_vec,
account.download_batch_size.unwrap_or(DEFAULT_BATCH_SIZE) as usize,
false,
);
DownloadState::update_folder_progress(
account_id,
@@ -212,14 +199,16 @@ pub async fn fetch_and_save_by_date(
)?;
}
session.logout().await.ok();
Ok(())
Ok(max_uid)
}
/// Fetches all messages from a mailbox.
/// Returns `Ok(Some(max_uid))` with the highest UID stored, or `Ok(None)` if empty.
pub async fn fetch_and_save_full_mailbox(
account: &AccountModel,
mailbox: &MailBox,
token: CancellationToken,
) -> BichonResult<()> {
) -> BichonResult<Option<u32>> {
let mailbox_id = mailbox.id;
let account_id = account.id;
@@ -262,33 +251,17 @@ pub async fn fetch_and_save_full_mailbox(
}
};
let folder_limit = account.folder_limit;
let total_to_fetch = match folder_limit {
Some(limit) if (limit as u64) < total => {
let limit64 = limit as u64;
total.min(limit64.max(100))
}
_ => total,
};
let page_size = if let Some(limit) = folder_limit {
limit
.max(100)
.min(account.download_batch_size.unwrap_or(DEFAULT_BATCH_SIZE))
} else {
account.download_batch_size.unwrap_or(DEFAULT_BATCH_SIZE)
};
let total_batches = total_to_fetch.div_ceil(page_size as u64);
let desc = folder_limit.is_some();
let page_size = account.download_batch_size.unwrap_or(DEFAULT_BATCH_SIZE);
let total_batches = total.div_ceil(page_size as u64);
info!(
"Starting full mailbox download for '{}', total={}, limit={:?}, batches={}, desc={}",
mailbox.name, total, folder_limit, total_batches, desc
"Starting full mailbox download for '{}', total={}, batches={}",
mailbox.name, total, total_batches
);
let mut current_processed = 0u64;
let mut has_error_or_cancel = false;
let mut max_uid: Option<u32> = None;
for page in 1..=total_batches {
if token.is_cancelled() {
@@ -300,7 +273,7 @@ pub async fn fetch_and_save_full_mailbox(
DownloadState::update_folder_progress(
account_id,
mailbox.name.clone(),
total_to_fetch,
total,
current_processed,
FolderStatus::Cancelled,
None,
@@ -313,12 +286,12 @@ pub async fn fetch_and_save_full_mailbox(
&mut session,
account_id,
mailbox_id,
total_to_fetch,
total,
page as u64,
page_size as u64,
&mailbox.encoded_name(),
desc,
token.clone(),
&mut max_uid,
)
.await
{
@@ -327,7 +300,7 @@ pub async fn fetch_and_save_full_mailbox(
DownloadState::update_folder_progress(
account_id,
mailbox.name.clone(),
total_to_fetch,
total,
current_processed,
FolderStatus::Downloading,
None,
@@ -339,7 +312,7 @@ pub async fn fetch_and_save_full_mailbox(
DownloadState::update_folder_progress(
account_id,
mailbox.name.clone(),
total_to_fetch,
total,
current_processed,
FolderStatus::Failed,
Some(err_msg),
@@ -354,71 +327,14 @@ pub async fn fetch_and_save_full_mailbox(
DownloadState::update_folder_progress(
account_id,
mailbox.name.clone(),
total_to_fetch,
total,
current_processed,
FolderStatus::Success,
None,
)?;
}
session.logout().await.ok();
Ok(())
}
pub fn generate_uid_sequence_hashset(
unique_nums: Vec<u32>,
chunk_size: usize,
desc: bool,
) -> Vec<(String, u64)> {
assert!(!unique_nums.is_empty());
let mut nums = unique_nums;
if desc {
nums.reverse();
}
let mut result = Vec::new();
for chunk in nums.chunks(chunk_size) {
let size = chunk.len() as u64;
let compressed = compress_uid_list(chunk.to_vec());
result.push((compressed, size));
}
result
}
pub fn compress_uid_list(nums: Vec<u32>) -> String {
if nums.is_empty() {
return String::new();
}
let mut sorted_nums = nums;
sorted_nums.sort();
let mut result = Vec::new();
let mut current_range_start = sorted_nums[0];
let mut current_range_end = sorted_nums[0];
for &n in sorted_nums.iter().skip(1) {
if n == current_range_end + 1 {
current_range_end = n;
} else {
if current_range_start == current_range_end {
result.push(current_range_start.to_string());
} else {
result.push(format!("{}:{}", current_range_start, current_range_end));
}
current_range_start = n;
current_range_end = n;
}
}
if current_range_start == current_range_end {
result.push(current_range_start.to_string());
} else {
result.push(format!("{}:{}", current_range_start, current_range_end));
}
result.join(",")
Ok(max_uid)
}
pub async fn reconcile_mailboxes(
@@ -448,7 +364,7 @@ pub async fn reconcile_mailboxes(
break;
}
if local_mailbox.uid_validity != remote_mailbox.uid_validity {
let new_highest_uid = if local_mailbox.uid_validity != remote_mailbox.uid_validity {
if remote_mailbox.uid_validity.is_none() {
let err_msg = format!(
"Mailbox '{}' logic error: Server did not provide UIDVALIDITY.",
@@ -493,7 +409,7 @@ pub async fn reconcile_mailboxes(
FetchDirection::Since,
token.clone(),
)
.await?;
.await?
}
None => match &account.date_before {
Some(r) => {
@@ -505,7 +421,7 @@ pub async fn reconcile_mailboxes(
FetchDirection::Before,
token.clone(),
)
.await?;
.await?
}
None => {
rebuild_mailbox_cache(
@@ -520,10 +436,12 @@ pub async fn reconcile_mailboxes(
}
} else {
perform_incremental_sync(account, local_mailbox, remote_mailbox, token.clone())
.await?;
}
.await?
};
mailboxes_to_update.push(remote_mailbox.clone());
let mut updated = remote_mailbox.clone();
updated.highest_uid = new_highest_uid;
mailboxes_to_update.push(updated);
}
//The metadata of this mailbox must only be updated after a successful synchronization;
//otherwise, it may cause synchronization errors and result in missing emails in the local sync results.
@@ -598,7 +516,11 @@ pub async fn reconcile_mailboxes(
};
match result {
Ok(_) => {}
Ok(new_highest_uid) => {
let mut updated = mailbox.clone();
updated.highest_uid = new_highest_uid;
MailBox::batch_upsert(&[updated])?;
}
Err(err) => {
has_error = true;
tracing::error!("Folder sync task failed: {:#?}", err);
@@ -622,57 +544,102 @@ pub async fn reconcile_mailboxes(
}
//only check new emails and sync
/// Incrementally syncs a mailbox.
/// Returns the new highest UID after sync, or `None` if nothing changed.
async fn perform_incremental_sync(
account: &AccountModel,
local_mailbox: &MailBox,
remote_mailbox: &MailBox,
token: CancellationToken,
) -> BichonResult<()> {
) -> BichonResult<Option<u32>> {
if remote_mailbox.exists > 0 {
let local_max_uid = ENVELOPE_MANAGER.get_max_uid(account.id, local_mailbox.id)?;
match local_max_uid {
Some(max_uid) => {
let mut session = ImapExecutor::create_connection(account.id).await?;
let before_date = account
.date_before
.as_ref()
.map(|r| r.calculate_date())
.transpose()?;
ImapExecutor::fetch_new_mail(
&mut session,
account,
local_mailbox,
max_uid + 1,
before_date.as_deref(),
token,
)
.await?;
session.logout().await.ok();
// Use stored highest_uid if available; otherwise fall back to Tantivy
// query once (backward compatibility with pre-existing databases).
let start_uid = match local_mailbox.highest_uid {
Some(uid) => {
tracing::info!(
"[account {}][mailbox {}] perform_incremental_sync: stored highest_uid={}, remote.exists={}",
account.id,
local_mailbox.name,
uid,
remote_mailbox.exists
);
uid as u64 + 1
}
None => {
info!(
"No maximum UID found in index for mailbox, assuming local cache is missing."
let local_max_uid =
ENVELOPE_MANAGER.get_max_uid(account.id, local_mailbox.id)?;
tracing::info!(
"[account {}][mailbox {}] perform_incremental_sync: highest_uid unset, Tantivy max_uid={:?}, remote.exists={}",
account.id,
local_mailbox.name,
local_max_uid,
remote_mailbox.exists
);
match &account.date_since {
Some(date_since) => {
fetch_and_save_by_date(
account,
date_since.since_date()?.as_str(),
remote_mailbox,
FetchDirection::Since,
token,
)
.await?;
}
match local_max_uid {
Some(uid) => uid + 1,
None => {
fetch_and_save_full_mailbox(account, remote_mailbox, token).await?;
info!(
"No maximum UID found in index for mailbox, assuming local storage is missing."
);
let result = match &account.date_since {
Some(date_since) => {
fetch_and_save_by_date(
account,
date_since.since_date()?.as_str(),
remote_mailbox,
FetchDirection::Since,
token,
)
.await?
}
None => match &account.date_before {
Some(r) => {
fetch_and_save_by_date(
account,
&r.calculate_date()?,
remote_mailbox,
FetchDirection::Before,
token,
)
.await?
}
None => {
fetch_and_save_full_mailbox(
account, remote_mailbox, token,
)
.await?
}
},
};
return Ok(result);
}
}
}
}
}
};
Ok(())
let mut session = ImapExecutor::create_connection(account.id).await?;
let before_date = account
.date_before
.as_ref()
.map(|r| r.calculate_date())
.transpose()?;
let new_max_uid = ImapExecutor::fetch_new_mail(
&mut session,
account,
local_mailbox,
start_uid,
before_date.as_deref(),
token,
)
.await?;
session.logout().await.ok();
// Keep existing highest_uid if no new mail was fetched.
Ok(new_max_uid.or(local_mailbox.highest_uid))
} else {
Ok(local_mailbox.highest_uid)
}
}

View File

@@ -89,7 +89,11 @@ pub async fn rebuild_cache(
};
match fetch_and_save_full_mailbox(&account, &mailbox, token.clone()).await {
Ok(_) => {}
Ok(new_highest_uid) => {
let mut updated = mailbox.clone();
updated.highest_uid = new_highest_uid;
MailBox::batch_upsert(&[updated])?;
}
Err(err) => {
has_error = true;
tracing::error!("Folder sync task failed: {:#?}", err);
@@ -169,7 +173,11 @@ pub async fn rebuild_cache_by_date(
match fetch_and_save_by_date(&account, date.as_str(), &mailbox, direction, token.clone())
.await
{
Ok(_) => {}
Ok(new_highest_uid) => {
let mut updated = mailbox.clone();
updated.highest_uid = new_highest_uid;
MailBox::batch_upsert(&[updated])?;
}
Err(err) => {
has_error = true;
tracing::error!("Folder sync task failed: {:#?}", err);
@@ -196,7 +204,7 @@ pub async fn rebuild_mailbox_cache(
local_mailbox: &MailBox,
remote_mailbox: &MailBox,
token: CancellationToken,
) -> BichonResult<()> {
) -> BichonResult<Option<u32>> {
ENVELOPE_MANAGER
.delete_mailbox_envelopes(account.id, vec![local_mailbox.id])
.await?;
@@ -217,11 +225,11 @@ pub async fn rebuild_mailbox_cache(
FolderStatus::Success,
None,
)?;
return Ok(());
return Ok(None);
}
fetch_and_save_full_mailbox(account, remote_mailbox, token).await?;
Ok(())
let result = fetch_and_save_full_mailbox(account, remote_mailbox, token).await?;
Ok(result)
}
pub async fn rebuild_mailbox_cache_by_date(
@@ -231,7 +239,7 @@ pub async fn rebuild_mailbox_cache_by_date(
remote: &MailBox,
direction: FetchDirection,
token: CancellationToken,
) -> BichonResult<()> {
) -> BichonResult<Option<u32>> {
ENVELOPE_MANAGER
.delete_mailbox_envelopes(account.id, vec![local_mailbox_id])
.await?;
@@ -252,9 +260,9 @@ pub async fn rebuild_mailbox_cache_by_date(
FolderStatus::Success,
None,
)?;
return Ok(());
return Ok(None);
}
fetch_and_save_by_date(account, date, remote, direction, token).await?;
Ok(())
let result = fetch_and_save_by_date(account, date, remote, direction, token).await?;
Ok(result)
}

View File

@@ -56,6 +56,10 @@ pub struct MailBox {
/// The validity identifier for UIDs in this mailbox, used to ensure UID consistency across sessions.
/// If `None`, the IMAP server has not provided this information.
pub uid_validity: Option<u32>,
/// The highest UID that has been successfully downloaded and stored locally.
/// Used for incremental sync: next fetch starts from `highest_uid + 1`.
/// If `None`, a fallback query against the Tantivy index will be performed once.
pub highest_uid: Option<u32>,
}
impl MemDbModel for MailBox {

View File

@@ -197,33 +197,37 @@ async fn extract_envelope_core(
let attachment_docs: Vec<TantivyDocument> = attachments
.iter()
.filter(|a| !a.inline || a.content_id.is_none())
.map(|a| AttachmentModel {
id: Uuid::new_v4().to_string(),
envelope_id: envelope_id.clone(),
account_id,
account_email: None,
mailbox_id,
mailbox_name: None,
subject: subject.clone(),
content_hash: a.content_hash.clone(),
from: from.clone(),
date,
ingest_at: now,
size: a.size as u64,
ext: a.get_extension(),
category: a.get_category().to_string(),
content_type: a.file_type.clone(),
shard_id: 0,
text: None,
has_text: false,
is_ocr: false,
page_count: None,
is_indexed: false,
is_message: a.is_message,
name: a.filename.clone(),
tags: None,
auto_tags: None,
}).map(|a|a.into_document())
.map(|a| {
let has_text = a.extracted_text.is_some();
AttachmentModel {
id: Uuid::new_v4().to_string(),
envelope_id: envelope_id.clone(),
account_id,
account_email: None,
mailbox_id,
mailbox_name: None,
subject: subject.clone(),
content_hash: a.content_hash.clone(),
from: from.clone(),
date,
ingest_at: now,
size: a.size as u64,
ext: a.get_extension(),
category: a.get_category().to_string(),
content_type: a.file_type.clone(),
shard_id: 0,
text: a.extracted_text.clone(),
has_text,
is_ocr: a.extracted_is_ocr,
page_count: a.extracted_page_count.map(|n| n as u64),
is_indexed: has_text,
is_message: a.is_message,
name: a.filename.clone(),
tags: None,
auto_tags: None,
}
})
.map(|a| a.into_document())
.collect();
let envelope = Envelope {
@@ -256,6 +260,14 @@ async fn extract_envelope_core(
attachments: Some(attachments),
};
let doc = ea.to_document(&body_text, 0)?;
tracing::debug!(
"[account {}][mailbox {}] extract: uid={} msg_id={} content_hash={}",
account_id,
mailbox_id,
uid,
&ea.envelope.message_id,
&ea.envelope.content_hash,
);
ENVELOPE_MANAGER.queue(doc).await;
for doc in attachment_docs {
ATTACHMENT_MANAGER.queue(doc).await;
@@ -389,6 +401,16 @@ pub async fn detach_and_store_attachments(
ranges.sort_by(|a, b| b.0.cmp(&a.0));
let mut attachments = Vec::with_capacity(ranges.len());
// Collect candidates for text extraction (non-inline, known document types).
struct TextCandidate {
content_hash: String,
file_type: String,
ext: String,
bytes: Vec<u8>,
}
let mut text_candidates: Vec<TextCandidate> = Vec::new();
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];
@@ -403,30 +425,88 @@ pub async fn detach_and_store_attachments(
let p_bytes = placeholder.as_bytes();
stripped_eml.splice(raw_start..raw_end, p_bytes.iter().cloned());
let inline = att
.content_disposition()
.map(|d| d.is_inline())
.unwrap_or(false);
let file_type = att
.content_type()
.map(|ct| {
format!(
"{}/{}",
ct.c_type.as_ref(),
ct.c_subtype.as_deref().unwrap_or("")
)
})
.unwrap_or_else(|| "application/octet-stream".to_string());
let has_cid = att.content_id().is_some();
let ext = att
.attachment_name()
.and_then(|n| {
std::path::Path::new(&n)
.extension()
.and_then(|e| e.to_str())
.map(|s| s.to_ascii_lowercase())
})
.unwrap_or_default();
if !inline || !has_cid {
let decoded_len = att.contents().len();
if decoded_len <= crate::ext::text_extractor::MAX_EXTRACT_BYTES
&& crate::ext::text_extractor::should_try_extract(&file_type, &ext)
{
text_candidates.push(TextCandidate {
content_hash: content_hash.clone(),
file_type: file_type.clone(),
ext: ext.clone(),
bytes: att.contents().to_vec(),
});
}
}
let info = AttachmentInfo {
filename: att.attachment_name().map(|n| n.to_string()),
size: att.contents().len(),
inline: att
.content_disposition()
.map(|d| d.is_inline())
.unwrap_or(false),
file_type: att
.content_type()
.map(|ct| {
format!(
"{}/{}",
ct.c_type.as_ref(),
ct.c_subtype.as_deref().unwrap_or("")
)
})
.unwrap_or_else(|| "application/octet-stream".to_string()),
inline,
file_type,
content_id: att.content_id().map(|id| id.to_string()),
content_hash: content_hash.clone(),
is_message: att.is_message(),
extracted_text: None,
extracted_page_count: None,
extracted_is_ocr: false,
};
attachment_infos.push(info);
}
// Run text extraction in a single spawn_blocking batch.
if !text_candidates.is_empty() {
if let Ok(mut extracted_map) = tokio::task::spawn_blocking(move || {
let mut map: std::collections::HashMap<
String,
(String, Option<u32>, bool),
> = std::collections::HashMap::new();
for c in text_candidates {
if let Some(r) =
crate::ext::text_extractor::extract_text(&c.file_type, &c.ext, &c.bytes)
{
map.insert(c.content_hash, (r.text, r.page_count, r.is_ocr));
}
}
map
})
.await
{
for info in &mut attachment_infos {
if let Some((text, pages, is_ocr)) = extracted_map.remove(&info.content_hash) {
info.extracted_text = Some(text);
info.extracted_page_count = pages;
info.extracted_is_ocr = is_ocr;
}
}
}
}
// Step 4: Store the final stripped EML content
BLOB_MANAGER
.queue(DetachedEmail {

View File

@@ -53,6 +53,21 @@ pub fn set_extractor(extractor: Box<dyn AttachmentTextExtractor>) {
*EXTRACTOR.write().unwrap() = extractor;
}
/// Attachments larger than this are skipped (10 MiB). Avoids excessive memory
/// and CPU cost for huge files whose text is rarely useful for search.
pub const MAX_EXTRACT_BYTES: usize = 10 * 1024 * 1024;
/// Quick pre-filter: returns true for file types where text extraction may
/// produce useful results. Avoids cloning attachment bytes for images, videos,
/// archives, etc. when no registered extractor would handle them.
pub fn should_try_extract(content_type: &str, ext: &str) -> bool {
matches!(
ext,
"pdf" | "doc" | "docx" | "xls" | "xlsx" | "ppt" | "pptx"
| "txt" | "rtf" | "odt" | "ods" | "odp"
) || content_type.starts_with("text/")
}
/// Called by the attachment pipeline during IMAP sync.
/// The caller should wrap this in spawn_blocking for CPU-bound extraction.
pub fn extract_text(content_type: &str, ext: &str, bytes: &[u8]) -> Option<ExtractedText> {

View File

@@ -17,8 +17,7 @@
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use crate::account::migration::AccountModel;
use crate::account::state::{DownloadState, FolderStatus};
use crate::cache::imap::download::flow::{generate_uid_sequence_hashset, DEFAULT_BATCH_SIZE};
use crate::account::state::{DownloadState, DownloadStatus, FolderStatus};
use crate::cache::imap::mailbox::MailBox;
use crate::envelope::extractor::extract_envelope_and_store_it;
use crate::error::code::ErrorCode;
@@ -80,6 +79,15 @@ impl ImapExecutor {
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::ImapCommandFailed))
}
/// Fetches new mail for a mailbox.
///
/// When `before` is `Some(date)`, a two-step approach is used:
/// `UID SEARCH` to find matching UIDs (standard IMAP), then batch `UID FETCH`
/// for the specific UIDs. When `before` is `None`, a direct ranged
/// `UID FETCH {start}:*` is issued and results are streamed.
///
/// Returns `Ok(Some(max_uid))` with the highest UID fetched, or `Ok(None)`
/// if no new mail was found.
pub async fn fetch_new_mail(
session: &mut Session<Box<dyn SessionStream>>,
account: &AccountModel,
@@ -87,114 +95,197 @@ impl ImapExecutor {
start_uid: u64,
before: Option<&str>,
token: CancellationToken,
) -> BichonResult<()> {
) -> BichonResult<Option<u32>> {
assert!(start_uid > 0, "start_uid must be greater than 0");
let query = match before {
Some(date) => format!("UID {start_uid}:* BEFORE {date}"),
None => format!("UID {start_uid}:*"),
};
session
.examine(&mailbox.encoded_name())
.await
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::ImapCommandFailed))?;
let uid_list = match Self::uid_search(session, &mailbox.encoded_name(), &query).await {
Ok(uid_list) => uid_list,
Err(e) => {
let err_msg = format!("UID search failed in [{}]: {:#?}", mailbox.name, e);
DownloadState::update_folder_progress(
account.id,
mailbox.name.clone(),
0,
0,
FolderStatus::Failed,
Some(err_msg.clone()),
)?;
DownloadState::append_session_error(account.id, err_msg)?;
return Err(e);
match before {
Some(date) => {
Self::fetch_new_mail_with_before(session, account, mailbox, start_uid, date, token)
.await
}
};
None => Self::fetch_new_mail_range(session, account, mailbox, start_uid, token).await,
}
}
let len = uid_list.len();
if len == 0 {
let msg = match before {
Some(date) => format!("No emails found before {}.", date),
None => "No new emails found.".into(),
};
/// Two-step approach for date-filtered incremental fetch: UID SEARCH first,
/// then batch UID FETCH for matching UIDs. Uses standard IMAP syntax that
/// works across all compliant servers.
async fn fetch_new_mail_with_before(
session: &mut Session<Box<dyn SessionStream>>,
account: &AccountModel,
mailbox: &MailBox,
start_uid: u64,
date: &str,
token: CancellationToken,
) -> BichonResult<Option<u32>> {
let query = format!("UID {start_uid}:* BEFORE {date}");
info!(
"[account {}][mailbox {}] fetch_new_mail: UID SEARCH {}",
account.id, mailbox.name, query
);
let results = session.uid_search(&query).await.map_err(|e| {
let err_msg = format!("UID SEARCH failed in [{}]: {:#?}", mailbox.name, e);
let _ = DownloadState::append_session_error(account.id, err_msg);
raise_error!(format!("{:#?}", e), ErrorCode::ImapCommandFailed)
})?;
if results.is_empty() {
DownloadState::update_folder_progress(
account.id,
mailbox.name.clone(),
0,
0,
FolderStatus::Success,
Some(msg),
Some("No new emails found.".into()),
)?;
return Ok(());
return Ok(None);
}
info!(
"[account {}][mailbox {}] {} envelopes need to be fetched",
account.id, mailbox.name, len
);
let mut uid_vec: Vec<u32> = uid_list.into_iter().collect();
let mut uid_vec: Vec<u32> = results.into_iter().collect();
uid_vec.sort();
let uid_batches = generate_uid_sequence_hashset(
uid_vec,
account.download_batch_size.unwrap_or(DEFAULT_BATCH_SIZE) as usize,
false,
);
let mut current_processed = 0u64;
let mut has_error_or_cancel = false;
for (index, batch) in uid_batches.into_iter().enumerate() {
let max_uid = uid_vec.last().copied();
let planned = uid_vec.len() as u64;
let batch_size = account.download_batch_size.unwrap_or(DEFAULT_BATCH_SIZE) as usize;
let uid_batches = generate_uid_sequence_hashset(uid_vec, batch_size);
DownloadState::update_folder_progress(
account.id,
mailbox.name.clone(),
planned,
0,
FolderStatus::Pending,
None,
)?;
let mut count = 0u64;
for batch in uid_batches {
if token.is_cancelled() {
break;
DownloadState::update_session_status(
account.id,
DownloadStatus::Cancelled,
Some("User stopped or system shutdown".to_string()),
)?;
DownloadState::update_folder_progress(
account.id,
mailbox.name.clone(),
planned,
count,
FolderStatus::Cancelled,
None,
)?;
return Err(raise_error!(
"Stream cancelled".into(),
ErrorCode::InternalError
));
}
match Self::uid_batch_retrieve_emails(
Self::uid_batch_retrieve_emails(
session,
account.id,
mailbox.id,
&batch.0,
token.clone(),
)
.await
{
Ok(_) => {
current_processed += batch.1;
DownloadState::update_folder_progress(
account.id,
mailbox.name.clone(),
len as u64,
current_processed,
FolderStatus::Downloading,
None,
)?;
}
Err(e) => {
let err_msg = format!("Batch {} failed: {:#?}", index, e);
DownloadState::append_session_error(account.id, err_msg.clone())?;
DownloadState::update_folder_progress(
account.id,
mailbox.name.clone(),
len as u64,
current_processed,
FolderStatus::Failed,
Some(err_msg),
)?;
has_error_or_cancel = true;
break;
}
}
}
if !has_error_or_cancel {
.await?;
count += batch.1;
DownloadState::update_folder_progress(
account.id,
mailbox.name.clone(),
len as u64,
current_processed,
planned,
count,
FolderStatus::Downloading,
None,
)?;
}
DownloadState::update_folder_progress(
account.id,
mailbox.name.clone(),
count,
count,
FolderStatus::Success,
None,
)?;
Ok(max_uid)
}
/// Direct ranged UID FETCH without date filtering. Streams results from
/// the server in a single IMAP round-trip.
async fn fetch_new_mail_range(
session: &mut Session<Box<dyn SessionStream>>,
account: &AccountModel,
mailbox: &MailBox,
start_uid: u64,
token: CancellationToken,
) -> BichonResult<Option<u32>> {
let uid_range = format!("{start_uid}:*");
info!(
"[account {}][mailbox {}] fetch_new_mail: direct UID FETCH {}",
account.id, mailbox.name, uid_range
);
let mut stream = session
.uid_fetch(&uid_range, BODY_FETCH_COMMAND)
.await
.map_err(|e| {
let err_msg = format!("UID FETCH failed in [{}]: {:#?}", mailbox.name, e);
let _ = DownloadState::append_session_error(account.id, err_msg);
raise_error!(format!("{:#?}", e), ErrorCode::ImapCommandFailed)
})?;
let mut count = 0u64;
let mut max_uid: Option<u32> = None;
while let Some(fetch) = stream
.try_next()
.await
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::ImapCommandFailed))?
{
if token.is_cancelled() {
tracing::info!("Account {}: fetch_new_mail stream interrupted.", account.id);
DownloadState::update_session_status(
account.id,
DownloadStatus::Cancelled,
Some("User stopped or system shutdown".to_string()),
)?;
return Err(raise_error!(
"Stream cancelled".into(),
ErrorCode::InternalError
));
}
if let Some(uid) = fetch.uid {
max_uid = Some(max_uid.unwrap_or(0).max(uid));
}
extract_envelope_and_store_it(fetch, account.id, mailbox.id).await?;
count += 1;
}
if count == 0 {
DownloadState::update_folder_progress(
account.id,
mailbox.name.clone(),
0,
0,
FolderStatus::Success,
Some("No new emails found.".into()),
)?;
} else {
DownloadState::update_folder_progress(
account.id,
mailbox.name.clone(),
count,
count,
FolderStatus::Success,
None,
)?;
}
Ok(())
Ok(max_uid)
}
pub async fn batch_retrieve_emails(
@@ -205,36 +296,23 @@ impl ImapExecutor {
page: u64,
page_size: u64,
encoded_mailbox_name: &str,
desc: bool,
token: CancellationToken,
max_uid: &mut Option<u32>,
) -> BichonResult<usize> {
assert!(page > 0, "Page number must be greater than 0");
assert!(page_size > 0, "Page size must be greater than 0");
let (start, end) = if desc {
// Fetch messages starting from the newest (descending order)
let end = total.saturating_sub((page - 1) * page_size);
if end == 0 {
return Ok(0);
}
// Calculate start as end - page_size + 1 to avoid off-by-one errors
let start = end.saturating_sub(page_size - 1).max(1);
(start, end)
} else {
// Fetch messages starting from the oldest (ascending order)
let start = (page - 1) * page_size + 1;
if start > total {
return Ok(0);
}
// Calculate end, capped by the total number of messages
let end = (start + page_size - 1).min(total);
(start, end)
};
// Fetch messages starting from the oldest (ascending order).
let start = (page - 1) * page_size + 1;
if start > total {
return Ok(0);
}
let end = (start + page_size - 1).min(total);
let sequence_set = format!("{}:{}", start, end);
info!(
"Fetching mailbox '{}' messages: sequence {} (page {}, page_size {}, desc={})",
encoded_mailbox_name, sequence_set, page, page_size, desc
"Fetching mailbox '{}' messages: sequence {} (page {}, page_size {})",
encoded_mailbox_name, sequence_set, page, page_size
);
let mut stream = session
@@ -255,6 +333,9 @@ impl ImapExecutor {
ErrorCode::InternalError
));
}
if let Some(uid) = fetch.uid {
*max_uid = Some((*max_uid).unwrap_or(0).max(uid));
}
extract_envelope_and_store_it(fetch, account_id, mailbox_id).await?;
count += 1;
}
@@ -347,3 +428,120 @@ impl ImapExecutor {
ImapConnectionManager::build(account_id).await
}
}
pub const DEFAULT_BATCH_SIZE: u32 = 30;
/// Compresses a sorted list of UIDs into an IMAP sequence-set string.
/// Consecutive UIDs become ranges (e.g. `1:5`), non-consecutive are
/// comma-separated (e.g. `1:5,10,12:15`).
pub fn compress_uid_list(nums: Vec<u32>) -> String {
if nums.is_empty() {
return String::new();
}
let mut sorted_nums = nums;
sorted_nums.sort();
let mut result = Vec::new();
let mut current_range_start = sorted_nums[0];
let mut current_range_end = sorted_nums[0];
for &n in sorted_nums.iter().skip(1) {
if n == current_range_end + 1 {
current_range_end = n;
} else {
if current_range_start == current_range_end {
result.push(current_range_start.to_string());
} else {
result.push(format!("{}:{}", current_range_start, current_range_end));
}
current_range_start = n;
current_range_end = n;
}
}
if current_range_start == current_range_end {
result.push(current_range_start.to_string());
} else {
result.push(format!("{}:{}", current_range_start, current_range_end));
}
result.join(",")
}
/// Splits a sorted list of unique UIDs into compressed sequence-set batches.
/// Returns `Vec<(sequence_set_string, batch_count)>`.
pub fn generate_uid_sequence_hashset(
unique_nums: Vec<u32>,
chunk_size: usize,
) -> Vec<(String, u64)> {
assert!(!unique_nums.is_empty());
let mut result = Vec::new();
let nums = unique_nums;
for chunk in nums.chunks(chunk_size) {
let size = chunk.len() as u64;
let compressed = compress_uid_list(chunk.to_vec());
result.push((compressed, size));
}
result
}
#[cfg(test)]
mod test {
use super::*;
// ── compress_uid_list ──────────────────────────────────────────
#[test]
fn compress_empty() {
assert_eq!(compress_uid_list(vec![]), "");
}
#[test]
fn compress_single_uid() {
assert_eq!(compress_uid_list(vec![42]), "42");
}
#[test]
fn compress_consecutive_range() {
assert_eq!(compress_uid_list(vec![1, 2, 3, 4, 5]), "1:5");
}
#[test]
fn compress_mixed_ranges() {
assert_eq!(
compress_uid_list(vec![1, 2, 3, 5, 7, 8, 9, 10]),
"1:3,5,7:10"
);
}
#[test]
fn compress_gap_at_boundary() {
assert_eq!(compress_uid_list(vec![1, 2, 4, 5]), "1:2,4:5");
}
// ── generate_uid_sequence_hashset ──────────────────────────────
#[test]
fn batch_single_chunk() {
let batches = generate_uid_sequence_hashset(vec![1, 2, 3], 10);
assert_eq!(batches.len(), 1);
assert_eq!(batches[0].0, "1:3");
assert_eq!(batches[0].1, 3);
}
#[test]
fn batch_multiple_chunks() {
let batches = generate_uid_sequence_hashset(vec![1, 2, 3, 4, 5], 2);
assert_eq!(batches.len(), 3);
assert_eq!(batches[0].0, "1:2");
assert_eq!(batches[0].1, 2);
assert_eq!(batches[1].0, "3:4");
assert_eq!(batches[1].1, 2);
assert_eq!(batches[2].0, "5");
assert_eq!(batches[2].1, 1);
}
}

View File

@@ -105,6 +105,7 @@ impl ImportEmls {
unseen: None,
uid_next: None,
uid_validity: None,
highest_uid: None,
};
let mailbox_id = mailbox.id;
// Upsert the mailbox, creating it if it doesn't exist

View File

@@ -157,8 +157,13 @@ async fn fetch_remote_with_progress(account_id: u64) -> BichonResult<Vec<MailBox
mailbox.account_id = account_id;
mailbox.id = create_hash(account_id, &mailbox.name);
// Use STATUS instead of EXAMINE: gets MESSAGES/UNSEEN/UIDNEXT/UIDVALIDITY
// without selecting the mailbox, avoiding context switches.
let mx = session
.examine(mailbox_name.as_str())
.status(
mailbox_name.as_str(),
"(MESSAGES UNSEEN UIDNEXT UIDVALIDITY)",
)
.await
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::ImapCommandFailed))?;
mailbox.exists = mx.exists;
@@ -205,8 +210,13 @@ pub async fn convert_names_to_mailboxes(
mailbox.account_id = account_id;
mailbox.id = create_hash(account_id, &mailbox.name);
// Use STATUS instead of EXAMINE: gets MESSAGES/UNSEEN/UIDNEXT/UIDVALIDITY
// without selecting the mailbox, avoiding context switches.
let mx = session
.examine(mailbox_name.as_str())
.status(
mailbox_name.as_str(),
"(MESSAGES UNSEEN UIDNEXT UIDVALIDITY)",
)
.await
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::ImapCommandFailed))?;
mailbox.exists = mx.exists;

View File

@@ -47,6 +47,13 @@ pub struct AttachmentInfo {
/// Hash of the content.
pub content_hash: String,
pub is_message: bool,
/// Text extracted from the attachment body (Pro/Enterprise feature).
/// Populated during IMAP sync; None for inline attachments and unsupported file types.
pub extracted_text: Option<String>,
/// Page count reported by the extractor, if any.
pub extracted_page_count: Option<u32>,
/// Whether the extracted text came from OCR.
pub extracted_is_ocr: bool,
}
impl AttachmentInfo {
@@ -222,13 +229,16 @@ pub fn retrieve_email_content(
let is_message = attachment.is_message();
let content_hash = compute_content_hash(attachment.contents());
attachments.push(AttachmentInfo {
filename: filename.or(Some(content_hash.clone())), // Fallback to content_hash as the default filename if it is not provided.
filename: filename.or(Some(content_hash.clone())),
size: attachment.contents().len(),
inline,
file_type,
is_message,
content_hash,
content_id: attachment.content_id().map(Into::into),
extracted_text: None,
extracted_page_count: None,
extracted_is_ocr: false,
});
}
let mut has_remote_content = false;
@@ -320,13 +330,16 @@ pub fn retrieve_nested_eml_content(
filename: attachment
.attachment_name()
.map(|n| n.to_string())
.or(Some(content_hash.clone())), // Fallback to content_hash as the default filename if it is not provided.
.or(Some(content_hash.clone())),
size: attachment.contents().len(),
inline: is_inline,
file_type,
content_hash,
is_message: attachment.is_message(),
content_id: cid.map(Into::into),
extracted_text: None,
extracted_page_count: None,
extracted_is_ocr: false,
});
}

View File

@@ -114,6 +114,9 @@ pub fn detach_attachments_standalone(
content_id: att.content_id().map(|id| id.to_string()),
content_hash,
is_message: att.is_message(),
extracted_text: None,
extracted_page_count: None,
extracted_is_ocr: false,
});
}

View File

@@ -155,6 +155,10 @@ impl IndexManager {
pending_count
);
tokio::task::block_in_place(|| fatal_commit(&mut writer));
tracing::debug!(
"Tantivy attach: committed {} docs, pending reset to 0",
pending_count
);
pending_count = 0;
commit_interval.reset();
}
@@ -172,9 +176,12 @@ impl IndexManager {
_ = commit_interval.tick() => {
if pending_count > 0 {
let mut writer = writer.lock().await;
tracing::debug!(
"Tantivy attach: periodic commit ({} docs pending)",
pending_count
);
tokio::task::block_in_place(|| fatal_commit(&mut writer));
pending_count = 0;
tracing::debug!("Tantivy: Periodic commit finished.");
}
}
_ = shutdown.recv() => {

View File

@@ -255,11 +255,22 @@ 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,
tracing::debug!(
"dedup: account={} mailbox={} hash={}: {} copies, keeping eid={} ingest_at={}, deleting {}",
account_id,
_key.0,
&_key.1,
entries.len(),
&entries[0].email_id,
entries[0].ingest_at,
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!(

View File

@@ -170,6 +170,10 @@ impl IndexManager {
pending_count
);
tokio::task::block_in_place(|| fatal_commit(&mut writer));
tracing::debug!(
"Tantivy: committed {} docs, pending reset to 0",
pending_count
);
pending_count = 0;
commit_interval.reset();
}
@@ -187,9 +191,12 @@ impl IndexManager {
_ = commit_interval.tick() => {
if pending_count > 0 {
let mut writer = writer.lock().await;
tracing::debug!(
"Tantivy: periodic commit ({} docs pending)",
pending_count
);
tokio::task::block_in_place(|| fatal_commit(&mut writer));
pending_count = 0;
tracing::debug!("Tantivy: Periodic commit finished.");
}
}
_ = shutdown.recv() => {
@@ -690,7 +697,15 @@ impl IndexManager {
let agg_res = searcher
.search(query.as_ref(), &collector)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
Ok(Self::extract_max_uid(&agg_res))
let result = Self::extract_max_uid(&agg_res);
tracing::debug!(
"[account {}][mailbox {}] get_max_uid = {:?} (num_docs in searcher = {})",
account_id,
mailbox_id,
result,
searcher.num_docs()
);
Ok(result)
}
pub fn get_account_stats(&self, account_id: u64) -> BichonResult<AccountStats> {

View File

@@ -33,7 +33,6 @@ tracing.workspace = true
tokio.workspace = true
http.workspace = true
urlencoding.workspace = true
mimalloc.workspace = true
[dev-dependencies]
poem = { version = "3.1.12", features = ["test"] }

View File

@@ -16,12 +16,7 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use bichon_core::error::BichonResult;
use mimalloc::MiMalloc;
#[global_allocator]
static GLOBAL: MiMalloc = MiMalloc;
#[tokio::main]
async fn main() -> BichonResult<()> {

View File

@@ -22,6 +22,7 @@ use crate::common::log::Tracing;
use crate::common::tls::rustls_config;
use crate::common::timeout::{Timeout, TIMEOUT_HEADER};
use crate::error::handler::error_handler;
use crate::rest::public::features::get_features;
use crate::rest::public::login::login;
use crate::rest::public::status::get_status;
use bichon_core::common::signal::SIGNAL_MANAGER;
@@ -110,6 +111,7 @@ pub fn build_routes() -> impl Endpoint {
.nest("/api-docs/spec.json", spec_json)
.nest("/api-docs/spec.yaml", spec_yaml)
.nest("/oauth2/callback", get(oauth2_callback))
.nest("/api/v1/features", get(get_features))
.nest("/api/status", get(get_status))
.nest("/api/login", post(login))
.nest_no_strip("/api/v1", open_api_route);

View File

@@ -0,0 +1,36 @@
//
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use poem::{handler, web::Json, IntoResponse};
use serde::Serialize;
#[derive(Serialize)]
struct FeaturesResponse {
features: Vec<String>,
edition: &'static str,
version: String,
}
#[handler]
pub async fn get_features() -> impl IntoResponse {
Json(FeaturesResponse {
features: vec![],
edition: "community",
version: env!("CARGO_PKG_VERSION").to_string(),
})
}

View File

@@ -17,6 +17,7 @@
// along with this program. If not, see <http://www.gnu.org/licenses/>.
pub mod features;
pub mod login;
pub mod oauth2;
pub mod status;

View File

@@ -628,6 +628,7 @@ async fn parse_email(data: &[u8], session: &Session) -> BichonResult<()> {
unseen: None,
uid_next: None,
uid_validity: None,
highest_uid: None,
};
let mailbox_id = mailbox.id;

View File

@@ -128,7 +128,6 @@ export interface AccountModel {
capabilities?: string[];
date_since?: DateSelection;
date_before?: RelativeDate;
folder_limit?: number,
download_folders: string[];
download_interval_min?: number;
download_batch_size?: number;

View File

@@ -177,9 +177,6 @@
"everyMinutes": "كل {{minutes}} دقيقة",
"field": "الحقل",
"fixed": "ثابت",
"folderLimit": "حد المجلد",
"folderLimitDescription": "حدد عدد رسائل البريد الإلكتروني للمزامنة لكل مجلد (الحد الأدنى 100). اتركه فارغًا بلا حدود.",
"folderLimitPlaceholder": "مثال: 1000",
"folderSync": {
"autoSelectDescendants": "تحديد العناصر الفرعية تلقائيًا",
"autoSelectParents": "تحديد العناصر الأصلية تلقائيًا",
@@ -1675,8 +1672,6 @@
},
"validation": {
"emailRequired": "البريد الإلكتروني مطلوب",
"folderLimitMustBeAtLeast100": "يجب أن يكون حد المجلد 100 على الأقل",
"folderLimitMustBeNumber": "يجب أن يكون حد المجلد رقمًا",
"imapHostCannotBeEmpty": "لا يمكن أن يكون مُضيف IMAP فارغًا",
"imapHostRequired": "مُضيف IMAP مطلوب",
"imapPortMustBeLessThan65536": "يجب أن يكون منفذ IMAP أقل من 65536",
@@ -1695,4 +1690,4 @@
"singleRequestBatchSizeTooLarge": "يجب أن يكون حجم الدُفعة على الأكثر 200",
"singleRequestBatchSizeTooSmall": "يجب أن يكون حجم الدُفعة على الأقل 10"
}
}
}

View File

@@ -177,9 +177,6 @@
"everyMinutes": "hvert {{minutes}} minut",
"field": "Felt",
"fixed": "Fast",
"folderLimit": "Mappegrænse",
"folderLimitDescription": "Begræns antallet af e-mails, der skal synkroniseres pr. mappe (minimum 100). Lad stå tomt for ingen grænse.",
"folderLimitPlaceholder": "f.eks. 1000",
"folderSync": {
"autoSelectDescendants": "Vælg efterkommere automatisk",
"autoSelectParents": "Vælg forældre automatisk",
@@ -1675,8 +1672,6 @@
},
"validation": {
"emailRequired": "E-mail er påkrævet",
"folderLimitMustBeAtLeast100": "Mappegrænse skal være mindst 100",
"folderLimitMustBeNumber": "Mappegrænse skal være et tal",
"imapHostCannotBeEmpty": "IMAP-vært kan ikke være tom",
"imapHostRequired": "IMAP-vært er påkrævet",
"imapPortMustBeLessThan65536": "IMAP-port skal være mindre end 65536",
@@ -1695,4 +1690,4 @@
"singleRequestBatchSizeTooLarge": "Batchstørrelse skal være højst 200",
"singleRequestBatchSizeTooSmall": "Batchstørrelse skal være mindst 10"
}
}
}

View File

@@ -177,9 +177,6 @@
"everyMinutes": "alle {{minutes}} Minuten",
"field": "Feld",
"fixed": "Fest",
"folderLimit": "Ordnerlimit",
"folderLimitDescription": "Begrenzen Sie die Anzahl der pro Ordner zu synchronisierenden E-Mails (mindestens 100). Lassen Sie das Feld leer für keine Begrenzung.",
"folderLimitPlaceholder": "z.B. 1000",
"folderSync": {
"autoSelectDescendants": "Unterelemente automatisch auswählen",
"autoSelectParents": "Elternelemente automatisch auswählen",
@@ -1675,8 +1672,6 @@
},
"validation": {
"emailRequired": "E-Mail ist erforderlich",
"folderLimitMustBeAtLeast100": "Das Ordnerlimit muss mindestens 100 betragen",
"folderLimitMustBeNumber": "Das Ordnerlimit muss eine Zahl sein",
"imapHostCannotBeEmpty": "IMAP-Host darf nicht leer sein",
"imapHostRequired": "IMAP-Host ist erforderlich",
"imapPortMustBeLessThan65536": "Der IMAP-Port muss kleiner als 65536 sein",
@@ -1695,4 +1690,4 @@
"singleRequestBatchSizeTooLarge": "Die Stapelgröße darf höchstens 200 sein",
"singleRequestBatchSizeTooSmall": "Die Stapelgröße muss mindestens 10 sein"
}
}
}

View File

@@ -177,9 +177,6 @@
"everyMinutes": "every {{minutes}} minutes",
"field": "Field",
"fixed": "Fixed",
"folderLimit": "Folder Limit",
"folderLimitDescription": "Limit the number of emails to sync per folder (minimum 100). Leave empty for no limit.",
"folderLimitPlaceholder": "e.g. 1000",
"folderSync": {
"autoSelectDescendants": "Auto select descendants",
"autoSelectParents": "Auto select parents",
@@ -1675,8 +1672,6 @@
},
"validation": {
"emailRequired": "Email is required",
"folderLimitMustBeAtLeast100": "Folder limit must be at least 100",
"folderLimitMustBeNumber": "Folder limit must be a number",
"imapHostCannotBeEmpty": "IMAP host cannot be empty",
"imapHostRequired": "IMAP host is required",
"imapPortMustBeLessThan65536": "IMAP port must be less than 65536",
@@ -1695,4 +1690,4 @@
"singleRequestBatchSizeTooLarge": "Batch size must be at most 200",
"singleRequestBatchSizeTooSmall": "Batch size must be at least 10"
}
}
}

View File

@@ -177,9 +177,6 @@
"everyMinutes": "cada {{minutes}} minutos",
"field": "Campo",
"fixed": "Fija",
"folderLimit": "Límite de carpetas",
"folderLimitDescription": "Limita el número de correos a sincronizar por carpeta (mínimo 100). Deja vacío para no tener límite.",
"folderLimitPlaceholder": "ej. 1000",
"folderSync": {
"autoSelectDescendants": "Seleccionar automáticamente los descendientes",
"autoSelectParents": "Seleccionar automáticamente los padres",
@@ -1675,8 +1672,6 @@
},
"validation": {
"emailRequired": "El correo electrónico es obligatorio",
"folderLimitMustBeAtLeast100": "El límite de carpetas debe ser de al menos 100",
"folderLimitMustBeNumber": "El límite de carpetas debe ser un número",
"imapHostCannotBeEmpty": "El host IMAP no puede estar vacío",
"imapHostRequired": "El host IMAP es obligatorio",
"imapPortMustBeLessThan65536": "El puerto IMAP debe ser menor que 65536",
@@ -1695,4 +1690,4 @@
"singleRequestBatchSizeTooLarge": "El tamaño del lote debe ser como máximo 200",
"singleRequestBatchSizeTooSmall": "El tamaño del lote debe ser al menos 10"
}
}
}

View File

@@ -177,9 +177,6 @@
"everyMinutes": "joka {{minutes}} minuutti",
"field": "Kenttä",
"fixed": "Kiinteä",
"folderLimit": "Kansioraja",
"folderLimitDescription": "Rajoittaa synkronoitavien sähköpostien määrän kansiota kohden (vähintään 100). Jätä tyhjäksi ilman rajoitusta.",
"folderLimitPlaceholder": "esim. 1000",
"folderSync": {
"autoSelectDescendants": "Valitse alisolmut automaattisesti",
"autoSelectParents": "Valitse yläsolmut automaattisesti",
@@ -1675,8 +1672,6 @@
},
"validation": {
"emailRequired": "Sähköposti on pakollinen",
"folderLimitMustBeAtLeast100": "Kansiorajan on oltava vähintään 100",
"folderLimitMustBeNumber": "Kansiorajan on oltava numero",
"imapHostCannotBeEmpty": "IMAP-isäntä ei voi olla tyhjä",
"imapHostRequired": "IMAP-isäntä on pakollinen",
"imapPortMustBeLessThan65536": "IMAP-portin on oltava pienempi kuin 65536",
@@ -1695,4 +1690,4 @@
"singleRequestBatchSizeTooLarge": "Eräkoko tulee olla enintään 200",
"singleRequestBatchSizeTooSmall": "Eräkoko tulee olla vähintään 10"
}
}
}

View File

@@ -177,9 +177,6 @@
"everyMinutes": "toutes les {{minutes}} minutes",
"field": "Champ",
"fixed": "Fixe",
"folderLimit": "Limite de Dossiers",
"folderLimitDescription": "Limite le nombre d'e-mails à synchroniser par dossier (minimum 100). Laissez vide pour aucune limite.",
"folderLimitPlaceholder": "ex. 1000",
"folderSync": {
"autoSelectDescendants": "Sélectionner automatiquement les descendants",
"autoSelectParents": "Sélectionner automatiquement les parents",
@@ -1675,8 +1672,6 @@
},
"validation": {
"emailRequired": "L'adresse e-mail est obligatoire",
"folderLimitMustBeAtLeast100": "La limite de dossiers doit être d'au moins 100",
"folderLimitMustBeNumber": "La limite de dossiers doit être un nombre",
"imapHostCannotBeEmpty": "L'hôte IMAP ne peut pas être vide",
"imapHostRequired": "L'hôte IMAP est obligatoire",
"imapPortMustBeLessThan65536": "Le port IMAP doit être inférieur à 65536",
@@ -1695,4 +1690,4 @@
"singleRequestBatchSizeTooLarge": "La taille du lot doit être au plus 200",
"singleRequestBatchSizeTooSmall": "La taille du lot doit être au moins 10"
}
}
}

View File

@@ -177,9 +177,6 @@
"everyMinutes": "ogni {{minutes}} minuti",
"field": "Campo",
"fixed": "Fissa",
"folderLimit": "Limite Cartelle",
"folderLimitDescription": "Limita il numero di email da sincronizzare per cartella (minimo 100). Lascia vuoto per nessun limite.",
"folderLimitPlaceholder": "es. 1000",
"folderSync": {
"autoSelectDescendants": "Seleziona automaticamente i discendenti",
"autoSelectParents": "Seleziona automaticamente i genitori",
@@ -1675,8 +1672,6 @@
},
"validation": {
"emailRequired": "L'email è obbligatoria",
"folderLimitMustBeAtLeast100": "Il limite di cartelle deve essere almeno 100",
"folderLimitMustBeNumber": "Il limite di cartelle deve essere un numero",
"imapHostCannotBeEmpty": "L'host IMAP non può essere vuoto",
"imapHostRequired": "L'host IMAP è obbligatorio",
"imapPortMustBeLessThan65536": "La porta IMAP deve essere inferiore a 65536",
@@ -1695,4 +1690,4 @@
"singleRequestBatchSizeTooLarge": "La dimensione del batch deve essere al massimo 200",
"singleRequestBatchSizeTooSmall": "La dimensione del batch deve essere almeno 10"
}
}
}

View File

@@ -177,9 +177,6 @@
"everyMinutes": "{{minutes}}分ごと",
"field": "フィールド",
"fixed": "固定",
"folderLimit": "フォルダーの制限",
"folderLimitDescription": "フォルダーごとに同期するメールの数を制限します最小100。制限なしにする場合は空欄にしてください。",
"folderLimitPlaceholder": "例: 1000",
"folderSync": {
"autoSelectDescendants": "子項目を自動選択",
"autoSelectParents": "親項目を自動選択",
@@ -1675,8 +1672,6 @@
},
"validation": {
"emailRequired": "メールアドレスは必須です",
"folderLimitMustBeAtLeast100": "フォルダーの制限は100以上である必要があります",
"folderLimitMustBeNumber": "フォルダーの制限は数値である必要があります",
"imapHostCannotBeEmpty": "IMAPホストは空にできません",
"imapHostRequired": "IMAPホストは必須です",
"imapPortMustBeLessThan65536": "IMAPポートは65536未満である必要があります",
@@ -1695,4 +1690,4 @@
"singleRequestBatchSizeTooLarge": "バッチサイズは最大でも200でなければなりません",
"singleRequestBatchSizeTooSmall": "バッチサイズは最低でも10でなければなりません"
}
}
}

View File

@@ -177,9 +177,6 @@
"everyMinutes": "매 {{minutes}}분",
"field": "필드",
"fixed": "고정",
"folderLimit": "폴더 제한",
"folderLimitDescription": "폴더당 동기화할 이메일 수를 제한합니다(최소 100). 제한이 없으면 비워 두십시오.",
"folderLimitPlaceholder": "예: 1000",
"folderSync": {
"autoSelectDescendants": "하위 항목 자동 선택",
"autoSelectParents": "상위 항목 자동 선택",
@@ -1675,8 +1672,6 @@
},
"validation": {
"emailRequired": "이메일 주소는 필수입니다",
"folderLimitMustBeAtLeast100": "폴더 제한은 최소 100 이상이어야 합니다",
"folderLimitMustBeNumber": "폴더 제한은 숫자여야 합니다",
"imapHostCannotBeEmpty": "IMAP 호스트는 비워 둘 수 없습니다",
"imapHostRequired": "IMAP 호스트는 필수입니다",
"imapPortMustBeLessThan65536": "IMAP 포트는 65536보다 작아야 합니다",
@@ -1695,4 +1690,4 @@
"singleRequestBatchSizeTooLarge": "배치 크기는 최대 200이어야 합니다",
"singleRequestBatchSizeTooSmall": "배치 크기는 최소 10이어야 합니다"
}
}
}

View File

@@ -177,9 +177,6 @@
"everyMinutes": "elke {{minutes}} minuten",
"field": "Veld",
"fixed": "Vast",
"folderLimit": "Mappenlimiet",
"folderLimitDescription": "Beperk het aantal te synchroniseren e-mails per map (minimaal 100). Laat leeg voor geen limiet.",
"folderLimitPlaceholder": "bv. 1000",
"folderSync": {
"autoSelectDescendants": "Automatisch onderliggende items selecteren",
"autoSelectParents": "Automatisch bovenliggende items selecteren",
@@ -1675,8 +1672,6 @@
},
"validation": {
"emailRequired": "E-mail is vereist",
"folderLimitMustBeAtLeast100": "Mappenlimiet moet ten minste 100 zijn",
"folderLimitMustBeNumber": "Mappenlimiet moet een nummer zijn",
"imapHostCannotBeEmpty": "IMAP host mag niet leeg zijn",
"imapHostRequired": "IMAP host is vereist",
"imapPortMustBeLessThan65536": "IMAP poort moet kleiner zijn dan 65536",
@@ -1695,4 +1690,4 @@
"singleRequestBatchSizeTooLarge": "Batchgrootte moet hoogstens 200 zijn",
"singleRequestBatchSizeTooSmall": "Batchgrootte moet ten minste 10 zijn"
}
}
}

View File

@@ -177,9 +177,6 @@
"everyMinutes": "hvert {{minutes}} minutt",
"field": "Felt",
"fixed": "Fast",
"folderLimit": "Mappegrense",
"folderLimitDescription": "Begrens antall e-poster som skal synkroniseres per mappe (minimum 100). La stå tomt for ingen grense.",
"folderLimitPlaceholder": "f.eks. 1000",
"folderSync": {
"autoSelectDescendants": "Velg etterkommere automatisk",
"autoSelectParents": "Velg foreldre automatisk",
@@ -1675,8 +1672,6 @@
},
"validation": {
"emailRequired": "E-post er påkrevd",
"folderLimitMustBeAtLeast100": "Mappegrense må være minst 100",
"folderLimitMustBeNumber": "Mappegrense må være et tall",
"imapHostCannotBeEmpty": "IMAP-vert kan ikke være tom",
"imapHostRequired": "IMAP-vert er påkrevd",
"imapPortMustBeLessThan65536": "IMAP-port må være mindre enn 65536",
@@ -1695,4 +1690,4 @@
"singleRequestBatchSizeTooLarge": "Batchstørrelse må være maksimalt 200",
"singleRequestBatchSizeTooSmall": "Batchstørrelse må være minst 10"
}
}
}

View File

@@ -177,9 +177,6 @@
"everyMinutes": "co {{minutes}} minut",
"field": "Pole",
"fixed": "Dokładnie",
"folderLimit": "Limit folderu",
"folderLimitDescription": "Ogranicz liczbę wiadomości email, które należy synchronizować w jednym folderze (minimum 100). Pozostaw puste, aby nie było limitu.",
"folderLimitPlaceholder": "np. 1000",
"folderSync": {
"autoSelectDescendants": "Automatyczny wybór dzieci (podrzędnych)",
"autoSelectParents": "Automatyczny wybór rodziców (nadrzędnych)",
@@ -1675,8 +1672,6 @@
},
"validation": {
"emailRequired": "Email jest wymagany",
"folderLimitMustBeAtLeast100": "Limit folderów musi być liczbą nie mniejszą niż 100",
"folderLimitMustBeNumber": "Limit folderów musi być liczbą",
"imapHostCannotBeEmpty": "Host IMAP nie może być pusty",
"imapHostRequired": "Host IMAP jest wymagany",
"imapPortMustBeLessThan65536": "Port IMAP musi być liczbą w przedziale 0-65535",
@@ -1695,4 +1690,4 @@
"singleRequestBatchSizeTooLarge": "Rozmiar partii może wynosić maksymalnie 200",
"singleRequestBatchSizeTooSmall": "Rozmiar partii musi wynosić co najmniej 10"
}
}
}

View File

@@ -177,9 +177,6 @@
"everyMinutes": "A cada {{minutes}} minutos",
"field": "Campo",
"fixed": "Fixo",
"folderLimit": "Limite de Pasta",
"folderLimitDescription": "Limita o número de emails a sincronizar por pasta (mínimo 100). Deixe vazio para nenhum limite.",
"folderLimitPlaceholder": "Ex: 1000",
"folderSync": {
"autoSelectDescendants": "Selecionar automaticamente os descendentes",
"autoSelectParents": "Selecionar automaticamente os pais",
@@ -1675,8 +1672,6 @@
},
"validation": {
"emailRequired": "O endereço de email é obrigatório",
"folderLimitMustBeAtLeast100": "O Limite de Pasta deve ser pelo menos 100",
"folderLimitMustBeNumber": "O Limite de Pasta deve ser um número",
"imapHostCannotBeEmpty": "O Host IMAP não pode estar vazio",
"imapHostRequired": "O Host IMAP é obrigatório",
"imapPortMustBeLessThan65536": "A Porta IMAP deve ser menor que 65536",
@@ -1695,4 +1690,4 @@
"singleRequestBatchSizeTooLarge": "O tamanho do lote deve ser no máximo 200",
"singleRequestBatchSizeTooSmall": "O tamanho do lote deve ser pelo menos 10"
}
}
}

View File

@@ -177,9 +177,6 @@
"everyMinutes": "каждые {{minutes}} мин.",
"field": "Поле",
"fixed": "Фиксированная",
"folderLimit": "Лимит папки",
"folderLimitDescription": "Ограничить количество писем для синхронизации в папке (минимум 100). Оставьте пустым для снятия ограничений.",
"folderLimitPlaceholder": "например, 1000",
"folderSync": {
"autoSelectDescendants": "Автоматически выбирать дочерние элементы",
"autoSelectParents": "Автоматически выбирать родительские элементы",
@@ -1675,8 +1672,6 @@
},
"validation": {
"emailRequired": "Email обязателен",
"folderLimitMustBeAtLeast100": "Лимит папки должен быть не менее 100",
"folderLimitMustBeNumber": "Лимит папки должен быть числом",
"imapHostCannotBeEmpty": "IMAP хост не может быть пустым",
"imapHostRequired": "IMAP хост обязателен",
"imapPortMustBeLessThan65536": "IMAP порт должен быть меньше 65536",
@@ -1695,4 +1690,4 @@
"singleRequestBatchSizeTooLarge": "Размер пакета должен быть не более 200",
"singleRequestBatchSizeTooSmall": "Размер пакета должен быть не менее 10"
}
}
}

View File

@@ -177,9 +177,6 @@
"everyMinutes": "varje {{minutes}} minut",
"field": "Fält",
"fixed": "Fast",
"folderLimit": "Mappgräns",
"folderLimitDescription": "Begränsa antalet e-postmeddelanden att synkronisera per mapp (minst 100). Lämna tomt för ingen gräns.",
"folderLimitPlaceholder": "t.ex. 1000",
"folderSync": {
"autoSelectDescendants": "Välj underordnade automatiskt",
"autoSelectParents": "Välj överordnade automatiskt",
@@ -1675,8 +1672,6 @@
},
"validation": {
"emailRequired": "E-post krävs",
"folderLimitMustBeAtLeast100": "Mappgräns måste vara minst 100",
"folderLimitMustBeNumber": "Mappgräns måste vara ett tal",
"imapHostCannotBeEmpty": "IMAP-värd kan inte vara tom",
"imapHostRequired": "IMAP-värd krävs",
"imapPortMustBeLessThan65536": "IMAP-port måste vara mindre än 65536",
@@ -1695,4 +1690,4 @@
"singleRequestBatchSizeTooLarge": "Batchstorlek måste vara högst 200",
"singleRequestBatchSizeTooSmall": "Batchstorlek måste vara minst 10"
}
}
}

View File

@@ -177,9 +177,6 @@
"everyMinutes": "每 {{minutes}} 分鐘",
"field": "欄位",
"fixed": "固定",
"folderLimit": "資料夾限制",
"folderLimitDescription": "限制每個資料夾要同步的郵件數量(最小 100。若無限制請留空。",
"folderLimitPlaceholder": "例如1000",
"folderSync": {
"autoSelectDescendants": "自動選取子項",
"autoSelectParents": "自動選取父項",
@@ -1675,8 +1672,6 @@
},
"validation": {
"emailRequired": "電子郵件地址為必填項",
"folderLimitMustBeAtLeast100": "資料夾限制必須至少為 100",
"folderLimitMustBeNumber": "資料夾限制必須是數字",
"imapHostCannotBeEmpty": "IMAP 主機不可為空",
"imapHostRequired": "IMAP 主機為必填項",
"imapPortMustBeLessThan65536": "IMAP 連接埠必須小於 65536",
@@ -1695,4 +1690,4 @@
"singleRequestBatchSizeTooLarge": "批次大小必須最多為200",
"singleRequestBatchSizeTooSmall": "批次大小必須至少為10"
}
}
}

View File

@@ -177,9 +177,6 @@
"everyMinutes": "每 {{minutes}} 分钟",
"field": "字段",
"fixed": "固定",
"folderLimit": "文件夹限制",
"folderLimitDescription": "限制每个文件夹同步的邮件数量(最少 100。留空表示不限制。",
"folderLimitPlaceholder": "例如 1000",
"folderSync": {
"autoSelectDescendants": "自动选择子项",
"autoSelectParents": "自动选择父项",
@@ -1675,8 +1672,6 @@
},
"validation": {
"emailRequired": "邮箱为必填项",
"folderLimitMustBeAtLeast100": "文件夹限制必须至少为 100",
"folderLimitMustBeNumber": "文件夹限制必须是数字",
"imapHostCannotBeEmpty": "IMAP 主机不能为空",
"imapHostRequired": "IMAP 主机为必填项",
"imapPortMustBeLessThan65536": "IMAP 端口必须小于 65536",
@@ -1695,4 +1690,4 @@
"singleRequestBatchSizeTooLarge": "批大小必须最多为200",
"singleRequestBatchSizeTooSmall": "批大小必须至少为10"
}
}
}