initial commit

This commit is contained in:
rustmailer
2025-11-19 02:14:37 +08:00
commit 1a8f95117e
355 changed files with 54089 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
/target
.vscode
.idea
+7
View File
@@ -0,0 +1,7 @@
edition = "2021"
newline_style = "unix"
normalize_comments = true
wrap_comments = true
format_code_in_doc_comments = true
imports_granularity = "Crate"
group_imports = "StdExternalCrate"
Generated
+6343
View File
File diff suppressed because it is too large Load Diff
+114
View File
@@ -0,0 +1,114 @@
[package]
name = "bichon"
version = "0.0.1"
edition = "2021"
[[bin]]
name = "bichon"
path = "src/main.rs"
[features]
default = []
vendored-openssl = ["openssl-sys"]
[profile.release]
strip = true
lto = true
opt-level = 3
codegen-units = 1
[dependencies]
chrono = "0.4.42"
clap = { version = "4.5.51", features = ["derive", "env"] }
mimalloc = "0.1.48"
native_db = "0.8.2"
itertools = "0.14.0"
native_model = "0.4.20"
poem = { version = "3.1.12", features = ["embed", "compression", "rustls"] }
poem-derive = "3.1.12"
poem-openapi = { version = "5.1.16", features = [
"openapi-explorer",
"rapidoc",
"scalar",
"redoc",
"swagger-ui",
"email",
] }
ring = { version = "0.17.14", features = ["std"] }
serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.145"
tokio = { version = "1.48.0", features = ["full"] }
tracing = "0.1.41"
tracing-appender = "0.2.3"
tracing-subscriber = { version = "0.3.20", features = ["env-filter", "json"] }
base64 = "0.22.1"
snafu = "0.8.9"
reqwest = { version = "0.12.24", default-features = false, features = [
"json",
"stream",
"native-tls",
# "rustls-tls",
"blocking",
"socks",
] }
tokio-socks = "0.5.2"
http = "1.3.1"
regex = "1.12.2"
email_address = "0.2.9"
futures = "0.3.31"
utf7-imap = "0.3.2"
imap-proto = "0.16.6"
mail-parser = { version = '0.11.1', features = ["serde"] }
mail-send = "0.5.2"
tokio-rustls = { version = "0.26.4", default-features = false, features = [
"ring",
"tls12",
] }
timeago = "0.5.0"
ahash = "0.8.12"
oauth2 = { version = "5.0.0", features = ["reqwest-blocking"] }
url = { version = "2.5.7", features = ["serde"] }
sysinfo = "0.37.2"
num_cpus = "1.17.0"
cacache = { version = "13.1.0", default-features = false, features = [
"tokio-runtime",
"mmap",
] }
rand = "0.9.2"
encoding_rs = "0.8.35"
async-imap = { version = "0.11.1", default-features = false, features = [
"runtime-tokio",
"compress",
] }
webpki-roots = "1.0.4"
rustls = { version = "0.23.35", default-features = false, features = ["ring"] }
rustls-pki-types = "1.13.0"
tokio-io-timeout = "1.2.1"
bb8 = "0.9.0"
semver = "1.0.27"
governor = "0.10.2"
lru = "0.16.2"
mime_guess = "2.0.5"
hex = "0.4.3"
time = { version = "0.3.44", features = [
"formatting",
"parsing",
"local-offset",
] }
rust-embed = "8.9.0"
murmur3 = "0.5.2"
autoconfig = "0.4.0"
urlencoding = "2.1.3"
dashmap = "6.1.0"
# Statically links OpenSSL by compiling from source, avoiding system library dependencies
openssl-sys = { version = "0.9.111", optional = true, features = ["vendored"] }
gethostname = "1.1.0"
tantivy = { version = "0.25.0", features = ["quickwit", "zstd-compression"] }
itoa = "1.0.15"
html2text = "0.16.2"
bytes = "1.11.0"
[dev-dependencies]
#bincode = "1.3.3"
#secret-lib = "1.0.0"
tempfile = "3.23.0"
+70
View File
@@ -0,0 +1,70 @@
<html>
<head>
<style>
@media (prefers-color-scheme: dark) {
body {
background: #333;
color: white;
}
a {
color: skyblue;
}
}
.container {
font-family: sans-serif;
max-width: 800px;
margin: 0 auto;
}
.intro {
text-align: center;
}
.licenses-list {
list-style-type: none;
margin: 0;
padding: 0;
}
.license-used-by {
margin-top: -10px;
}
.license-text {
max-height: 200px;
overflow-y: scroll;
white-space: pre-wrap;
}
</style>
</head>
<body>
<main class="container">
<div class="intro">
<h1>Third Party Licenses</h1>
<p>This page lists the licenses of the projects used in Bichon.</p>
</div>
<h2>Overview of licenses:</h2>
<ul class="licenses-overview">
{{#each overview}}
<li><a href="#{{id}}">{{name}}</a> ({{count}})</li>
{{/each}}
</ul>
<h2>All license text:</h2>
<ul class="licenses-list">
{{#each licenses}}
<li class="license">
<h3 id="{{id}}">{{name}}</h3>
<h4>Used by:</h4>
<ul class="license-used-by">
{{#each used_by}}
<li><a href="{{#if crate.repository}} {{crate.repository}} {{else}} https://crates.io/crates/{{crate.name}} {{/if}}">{{crate.name}} {{crate.version}}</a></li>
{{/each}}
</ul>
<pre class="license-text">{{text}}</pre>
</li>
{{/each}}
</ul>
</main>
</body>
</html>
+18
View File
@@ -0,0 +1,18 @@
accepted = [
"Apache-2.0",
"MIT",
"BSD-3-Clause",
"MPL-2.0",
"ISC",
"Unicode-3.0",
"BSL-1.0",
"Unicode-DFS-2016",
"CDLA-Permissive-2.0",
"Zlib",
"Apache-2.0 WITH LLVM-exception",
"0BSD",
"BSD-2-Clause",
"NCSA",
"CC0-1.0",
"OpenSSL"
]
+14
View File
@@ -0,0 +1,14 @@
use std::{io::Result, process::Command};
fn main() -> Result<()> {
let output = Command::new("git")
.args(&["rev-parse", "--short", "HEAD"])
.output()
.expect("Failed to get git commit hash");
let git_hash = String::from_utf8(output.stdout)
.expect("Invalid UTF-8")
.trim()
.to_string();
println!("cargo:rustc-env=GIT_HASH={}", git_hash);
Ok(())
}
+1
View File
@@ -0,0 +1 @@
cargo build --release
+32
View File
@@ -0,0 +1,32 @@
[Unit]
Description=Bichon Email Service
After=network.target
Wants=network.target
[Service]
Type=simple
User=bichon
Group=bichon
WorkingDirectory=/opt/bichon
ExecStart=/opt/bichon/bichon
Restart=always
RestartSec=5s
EnvironmentFile=/etc/bichon.conf
StandardOutput=null
StandardError=null
LogsDirectory=bichon
LogsDirectoryMode=0755
LimitNOFILE=65536
LimitCORE=0
PrivateTmp=true
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=read-only
PrivateDevices=true
RestrictSUIDSGID=true
[Install]
WantedBy=multi-user.target
+58
View File
@@ -0,0 +1,58 @@
# configuration file for git-cliff (0.1.0)
[changelog]
# changelog header
header = """
# Bichon Changelog\n
"""
# template for the changelog body
# https://tera.netlify.app/docs/#introduction
body = """
{% if version %}\
## [{{ version | trim_start_matches(pat="v") }}] - {{ timestamp | date(format="%Y-%m-%d") }}
{% else %}\
## [unreleased]
{% endif %}\
{% for group, commits in commits | group_by(attribute="group") %}
### {{ group | upper_first }}
{% for commit in commits %}
- {% if commit.breaking %}[**breaking**] {% endif %}{{ commit.message | upper_first }}\
{% endfor %}
{% endfor %}\n
"""
# remove the leading and trailing whitespace from the template
trim = true
# changelog footer
footer = """
<!-- generated by git-cliff -->
"""
[git]
# parse the commits based on https://www.conventionalcommits.org
conventional_commits = false
# filter out the commits that are not conventional
filter_unconventional = false
# regex for preprocessing the commit messages
commit_preprocessors = [
{ pattern = '\((\w+\s)?#([0-9]+)\)', replace = "([#${2}](https://github.com/rustmailer/bichon/issues/${2}))"},
]
# regex for parsing and grouping commits
commit_parsers = [
{ message = "^feat\\(frontend\\):", group = "Frontend Features" },
{ message = "^fix\\(backend\\):", group = "Backend Fixes" },
{ message = "^docs:", group = "Documentation" },
{ message = "^ci:", group = "CI/CD" },
{ message = ".*", group = "Others" },
]
# filter out the commits that are not matched by commit parsers
filter_commits = false
# glob pattern for matching git tags
tag_pattern = "^[0-9]+(\\.[0-9]+){2}$"
# regex for skipping tags
#skip_tags = "v0.1.0-beta.1"
# regex for ignoring tags
ignore_tags = ""
# sort the tags chronologically
date_order = false
# sort the commits inside sections by oldest/newest order
sort_commits = "oldest"
+36
View File
@@ -0,0 +1,36 @@
# Use Debian bullseye-slim as the base image for newer OpenSSL
FROM ubuntu:24.04
ARG CRATE_VERSION
# Set maintainer metadata
LABEL maintainer="rustmailer <rustmailer.git@gmail.com>"
LABEL version="${CRATE_VERSION}"
LABEL description="Dockerized Bichon service"
# Set working directory
WORKDIR /opt/bichon
# Copy compiled binary (ensure it's statically linked or compatible with bullseye)
COPY bichon /opt/bichon/bichon
COPY LICENSE /opt/bichon/
COPY license.html /opt/bichon/
# Set proper permissions
RUN chmod +x /opt/bichon/bichon
# Install ca-certificates to ensure HTTPS certificate verification works correctly
RUN apt update && apt install -y ca-certificates && rm -rf /var/lib/apt/lists/*
# Create data directory
RUN mkdir -p /data
# Expose default ports
EXPOSE 15630
# Set volume and working directory
WORKDIR /data
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
CMD curl -fs http://localhost:15630/api/status || exit 1
# Entrypoint remains the binary
CMD ["/opt/bichon/bichon"]
+31
View File
@@ -0,0 +1,31 @@
#!/bin/bash
# Step 0: Build frontend with pnpm in ../web
echo "Step 0: Building frontend in ../web using pnpm..."
cd ../web || { echo "Failed to enter ../web directory"; exit 1; }
pnpm run build || { echo "Frontend build failed"; exit 1; }
# Step 1: Build Rust backend with cargo in project root
echo "Step 1: Building Rust backend with cargo..."
cd ../ || { echo "Failed to enter project root directory"; exit 1; }
cargo build --release || { echo "Rust backend build failed"; exit 1; }
# Step 2: Back to docker directory
echo "Step 2: Returning to docker directory..."
cd docker || { echo "Failed to enter docker directory"; exit 1; }
# Step 3: Extract version from Cargo.toml
VERSION=$(sed -n 's/^version = "\(.*\)"/\1/p' ../Cargo.toml)
echo "Step 3: Extracted version: $VERSION"
# Step 4: Copy the compiled RustMailer binary to current directory (docker/)
cp ../target/release/bichon .
echo "Step 4: Copied bichon binary"
# Step 5: Build Docker image with version tag
sudo docker build --build-arg CRATE_VERSION=$VERSION -t bichon:$VERSION .
echo "Step 5: Built Docker image with tag bichon:$VERSION"
# Step 6: Tag local image for Docker Hub
docker tag bichon:$VERSION billydong/bichon:$VERSION
echo "Step 6: Tagged image as billydong/bichon:$VERSION"
+44
View File
@@ -0,0 +1,44 @@
# Log level: trace, debug, info, warn, error
RUSTMAILER_LOG_LEVEL=info
# HTTP server port
RUSTMAILER_HTTP_PORT=15630
# Public URL for this RustMailer instance
RUSTMAILER_PUBLIC_URL=http://localhost:15630
# Enable ANSI-colored logs (for terminal output)
RUSTMAILER_ANSI_LOGS=false
# Enable JSON-formatted logs (for machine parsing)
RUSTMAILER_JSON_LOGS=false
# Persist logs to files
RUSTMAILER_LOG_TO_FILE=false
# Max number of server log files to retain
RUSTMAILER_MAX_SERVER_LOG_FILES=5
# Encryption password for stored secrets (change this in production!)
RUSTMAILER_ENCRYPT_PASSWORD=change-this-default-password-now
# Root directory for persistent storage (adjust to your environment)
RUSTMAILER_ROOT_DIR=/data/bichon-data
# Enable API access token validation
RUSTMAILER_ENABLE_ACCESS_TOKEN=false
# IP address to bind the HTTP and gRPC servers to (default: 0.0.0.0)
RUSTMAILER_BIND_IP=
# Comma-separated list of allowed CORS origins (e.g. https://app.example.com)
RUSTMAILER_CORS_ORIGINS=
# Cache duration (in seconds) for CORS preflight responses
RUSTMAILER_CORS_MAX_AGE=86400
# Enable HTTPS for REST API server
RUSTMAILER_ENABLE_REST_HTTPS=false
# Enable HTTP response compression (gzip or brotli)
RUSTMAILER_HTTP_COMPRESSION_ENABLED=true
+76
View File
@@ -0,0 +1,76 @@
//
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use mimalloc::MiMalloc;
use modules::{
common::rustls::RustMailerTls,
context::{executors::EmailClientExecutors, Initialize},
error::BichonResult,
logger,
rest::start_http_server,
tasks::PeriodicTasks,
token::root::ensure_root_token,
};
use tracing::info;
use crate::modules::{common::signal::SignalManager, settings::dir::DataDirManager};
mod modules;
#[global_allocator]
static GLOBAL: MiMalloc = MiMalloc;
static LOGO: &str = r#"
_ _ _
| | (_) | |
| |__ _ ___ | |__ ___ _ __
| '_ \ | | / __|| '_ \ / _ \ | '_ \
| |_) || || (__ | | | || (_) || | | |
|_.__/ |_| \___||_| |_| \___/ |_| |_|
"#;
#[tokio::main]
async fn main() -> BichonResult<()> {
logger::initialize_logging();
info!("{}", LOGO);
info!("Starting bichon-server");
info!("Version: {}", bichon_version!());
info!("Git: [{}]", env!("GIT_HASH"));
info!("GitHub: https://github.com/rustmailer/bichon");
if let Err(error) = initialize().await {
eprintln!("{:?}", error);
return Err(error);
}
start_http_server().await?;
Ok(())
}
/// Initialize the system by validating settings and starting necessary tasks.
async fn initialize() -> BichonResult<()> {
// SETTINGS.validate()?;
SignalManager::initialize().await?;
DataDirManager::initialize().await?;
ensure_root_token().await?;
RustMailerTls::initialize().await?;
EmailClientExecutors::initialize().await?;
PeriodicTasks::start_background_tasks();
Ok(())
}
+60
View File
@@ -0,0 +1,60 @@
//
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use crate::modules::account::state::AccountRunningState;
use std::sync::LazyLock;
use tokio::sync::mpsc;
use tracing::error;
pub static STATUS_DISPATCHER: LazyLock<ErrorDispatcher> = LazyLock::new(ErrorDispatcher::new);
pub struct ErrorDispatcher {
channel: mpsc::Sender<(u64, String)>,
}
impl ErrorDispatcher {
pub fn new() -> Self {
let (tx, mut rx) = mpsc::channel::<(u64, String)>(100);
tokio::spawn(async move {
while let Some((account_id, error)) = rx.recv().await {
match AccountRunningState::append_error_message(account_id, error).await {
Ok(()) => {}
Err(error) => {
error!(
"Failed to append error for account: {}. Error: {:#?}",
&account_id, error
);
}
}
}
});
ErrorDispatcher { channel: tx }
}
pub async fn append_error(&self, account_id: u64, error: String) {
if let Err(e) = self.channel.send((account_id, error.clone())).await {
error!(
"Failed to dispatch status update for account: {}, Error: {}. Channel error: {:?}",
&account_id, error, e
);
}
}
}
+120
View File
@@ -0,0 +1,120 @@
//
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use crate::{encrypt, modules::error::BichonResult};
use poem_openapi::{Enum, Object};
use serde::{Deserialize, Serialize};
#[derive(Clone, Default, Debug, Eq, PartialEq, Serialize, Deserialize, Object)]
pub struct ImapConfig {
/// IMAP server hostname or IP address
#[oai(validator(max_length = 253, pattern = r"^[a-zA-Z0-9\-\.]+$"))]
pub host: String,
/// IMAP server port number
#[oai(validator(minimum(value = "1"), maximum(value = "65535")))]
pub port: u16,
/// Connection encryption method
pub encryption: Encryption,
/// Authentication configuration
pub auth: AuthConfig,
/// Optional proxy ID for establishing the connection.
/// - If `None` or not provided, the client will connect directly to the IMAP server.
/// - If `Some(proxy_id)`, the client will use the pre-configured proxy with the given ID.
pub use_proxy: Option<u64>,
}
impl ImapConfig {
pub fn try_encrypt_password(self) -> BichonResult<Self> {
Ok(Self {
host: self.host,
port: self.port,
encryption: self.encryption,
auth: self.auth.encrypt()?,
use_proxy: self.use_proxy,
})
}
}
#[derive(Enum, Default, Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub enum AuthType {
/// Standard password authentication (PLAIN/LOGIN)
#[default]
Password,
/// OAuth 2.0 authentication (SASL XOAUTH2)
OAuth2,
}
#[derive(Object, Default, Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub struct AuthConfig {
///Authentication method to use
pub auth_type: AuthType,
/// Credential secret for Password authentication.
///
/// Users should provide a plaintext password (1 to 256 characters).
/// The server will encrypt the password using AES-256-GCM and securely store it.
/// The plaintext password is never stored, so users must remember it for authentication.
#[oai(validator(max_length = 256, min_length = 1))]
pub password: Option<String>,
}
impl AuthConfig {
pub fn encrypt(self) -> BichonResult<Self> {
match self.password {
Some(password) => Ok(Self {
auth_type: self.auth_type,
password: Some(encrypt!(&password)?),
}),
None => Ok(self),
}
}
}
impl AuthConfig {
pub fn validate(&self) -> Result<(), &'static str> {
match self.auth_type {
AuthType::Password if self.password.is_none() => {
Err("When auth_type is Passwd, password must not be None.")
}
_ => Ok(()),
}
}
}
#[derive(Clone, Default, Debug, Eq, PartialEq, Serialize, Deserialize, Enum)]
pub enum Encryption {
/// SSL/TLS encrypted connection
#[default]
Ssl,
/// StartTLS encryption
StartTls,
/// Unencrypted connection
None,
}
impl From<bool> for Encryption {
fn from(value: bool) -> Self {
if value {
Self::Ssl
} else {
Self::None
}
}
}
+357
View File
@@ -0,0 +1,357 @@
//
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use native_db::*;
use native_model::{native_model, Model};
use poem_openapi::{Enum, Object};
use serde::{Deserialize, Serialize};
use std::collections::BTreeSet;
use tracing::info;
use crate::{
encrypt,
modules::{
account::{entity::ImapConfig, since::DateSince, state::AccountRunningState},
cache::imap::mailbox::MailBox,
database::{insert_impl, list_all_impl},
error::BichonResult,
indexer::manager::{EML_INDEX_MANAGER, ENVELOPE_INDEX_MANAGER},
},
utc_now,
};
use crate::id;
use crate::modules::account::payload::AccountCreateRequest;
use crate::modules::account::payload::AccountUpdateRequest;
use crate::modules::account::payload::MinimalAccount;
use crate::modules::cache::imap::task::SYNC_TASKS;
use crate::modules::context::controller::SYNC_CONTROLLER;
use crate::modules::context::executors::MAIL_CONTEXT;
use crate::modules::database::count_by_unique_secondary_key_impl;
use crate::modules::database::delete_impl;
use crate::modules::database::manager::DB_MANAGER;
use crate::modules::database::{
paginate_query_primary_scan_all_impl, secondary_find_impl, update_impl,
};
use crate::modules::error::code::ErrorCode;
use crate::modules::oauth2::token::OAuth2AccessToken;
use crate::modules::rest::response::DataPage;
use crate::modules::token::AccessToken;
use crate::raise_error;
pub type AccountModel = AccountV1;
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize, Enum)]
pub enum AccountType {
#[default]
IMAP,
NoSync,
}
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize, Object)]
#[native_model(id = 4, version = 1)]
#[native_db(primary_key(pk -> String))]
pub struct AccountV1 {
#[secondary_key(unique)]
pub id: u64,
pub imap: Option<ImapConfig>,
pub enabled: bool,
#[oai(validator(custom = "crate::modules::common::validator::EmailValidator"))]
pub email: String,
pub name: Option<String>,
pub capabilities: Option<Vec<String>>,
pub date_since: Option<DateSince>,
pub folder_limit: Option<u32>,
pub sync_folders: Option<Vec<String>>,
pub account_type: AccountType,
pub sync_interval_min: Option<i64>,
pub known_folders: Option<BTreeSet<String>>,
pub created_at: i64,
pub updated_at: i64,
pub use_proxy: Option<u64>,
}
impl AccountV1 {
fn pk(&self) -> String {
format!("{}_{}", self.created_at, self.id)
}
pub fn new(request: AccountCreateRequest) -> BichonResult<Self> {
Ok(Self {
id: id!(64),
email: request.email,
name: request.name,
imap: request.imap.map(|i| i.try_encrypt_password()).transpose()?,
enabled: request.enabled,
capabilities: None,
date_since: request.date_since,
sync_folders: None,
known_folders: None,
account_type: request.account_type,
sync_interval_min: request.sync_interval_min,
created_at: utc_now!(),
updated_at: utc_now!(),
use_proxy: request.use_proxy,
folder_limit: request.folder_limit,
})
}
pub async fn check_account_active(account_id: u64) -> BichonResult<AccountModel> {
let account =
secondary_find_impl::<AccountModel>(DB_MANAGER.meta_db(), AccountV1Key::id, account_id)
.await?
.ok_or_else(|| {
raise_error!(
format!("Account id='{account_id}' not found"),
ErrorCode::ResourceNotFound
)
})?;
if !account.enabled {
return Err(raise_error!(
format!("Account id='{account_id}' is disabled"),
ErrorCode::AccountDisabled
));
}
Ok(account)
}
/// Fetches an `AccountEntity` by its `id`.
pub async fn get(account_id: u64) -> BichonResult<AccountModel> {
let result: AccountModel = Self::find(account_id).await?.ok_or_else(|| {
raise_error!(
format!("Account with ID '{account_id}' not found"),
ErrorCode::ResourceNotFound
)
})?;
Ok(result)
}
pub async fn find(account_id: u64) -> BichonResult<Option<AccountModel>> {
secondary_find_impl::<AccountModel>(DB_MANAGER.meta_db(), AccountV1Key::id, account_id)
.await
}
/// Saves the current `AccountEntity` by persisting it to storage.
pub async fn save(&self) -> BichonResult<()> {
insert_impl(DB_MANAGER.meta_db(), self.to_owned()).await
}
pub async fn create_account(request: AccountCreateRequest) -> BichonResult<AccountModel> {
let entity = request.create_entity()?;
entity.save().await?;
if matches!(entity.account_type, AccountType::IMAP) {
SYNC_CONTROLLER
.trigger_start(entity.id, entity.email.clone())
.await;
}
Ok(entity)
}
pub async fn update(
account_id: u64,
request: AccountUpdateRequest,
validate: bool,
) -> BichonResult<()> {
let account = AccountModel::get(account_id).await?;
if validate {
request.validate_update_request(&account)?;
}
update_impl(
DB_MANAGER.meta_db(),
move |_| Ok(account),
move |current| Self::apply_update_fields(current, request),
)
.await?;
Ok(())
}
pub async fn delete(account_id: u64) -> BichonResult<()> {
let account = Self::get(account_id).await?;
if let Err(error) = Self::cleanup_account_resources_sequential(&account).await {
tracing::error!(
"[CLEANUP_ACCOUNT_ERROR] Account {}: failed to cleanup resources: {:#?}",
account_id,
error
);
return Err(error);
}
Ok(())
}
async fn delete_account(account_id: u64) -> BichonResult<()> {
delete_impl(DB_MANAGER.meta_db(), move|rw|{
rw.get().secondary::<AccountModel>(AccountV1Key::id, account_id).map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
.ok_or_else(||raise_error!(format!("The account entity with id={account_id} that you want to delete was not found."), ErrorCode::ResourceNotFound))
}).await
}
async fn cleanup_account_resources_sequential(account: &AccountModel) -> BichonResult<()> {
if matches!(account.account_type, AccountType::IMAP) {
SYNC_TASKS.stop(account.id).await?;
AccountRunningState::delete(account.id).await?;
MAIL_CONTEXT.clean_account(account.id).await?;
}
OAuth2AccessToken::try_delete(account.id).await?;
AccessToken::cleanup_account(account.id).await?;
MailBox::clean(account.id).await?;
ENVELOPE_INDEX_MANAGER
.delete_account_envelopes(account.id)
.await?;
EML_INDEX_MANAGER
.delete_account_envelopes(account.id)
.await?;
Self::delete_account(account.id).await?;
info!("Sequential cleanup completed for account: {}", account.id);
Ok(())
}
pub async fn update_sync_folders(
account_id: u64,
sync_folders: Vec<String>,
) -> BichonResult<()> {
update_impl(DB_MANAGER.meta_db(), move |rw| {
rw.get().secondary::<AccountModel>(AccountV1Key::id, account_id).map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
.ok_or_else(|| raise_error!(format!("When trying to update account sync_folders, the corresponding record was not found. account_id={}", account_id), ErrorCode::ResourceNotFound))
}, |current|{
let mut updated = current.clone();
updated.sync_folders = Some(sync_folders);
Ok(updated)
}).await?;
Ok(())
}
pub async fn update_known_folders(
account_id: u64,
known_folders: BTreeSet<String>,
) -> BichonResult<()> {
update_impl(DB_MANAGER.meta_db(), move |rw| {
rw.get().secondary::<AccountModel>(AccountV1Key::id, account_id).map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
.ok_or_else(|| raise_error!(format!("When trying to update account known_folders, the corresponding record was not found. account_id={}", account_id), ErrorCode::ResourceNotFound))
}, |current|{
let mut updated = current.clone();
updated.known_folders = Some(known_folders);
Ok(updated)
}).await?;
Ok(())
}
pub async fn update_capabilities(
account_id: u64,
capabilities: Vec<String>,
) -> BichonResult<()> {
update_impl(DB_MANAGER.meta_db(), move |rw| {
rw.get().secondary::<AccountModel>(AccountV1Key::id, account_id).map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
.ok_or_else(|| raise_error!(format!("When trying to update account capabilities, the corresponding record was not found. account_id={}", account_id), ErrorCode::ResourceNotFound))
}, |current|{
let mut updated = current.clone();
updated.capabilities = Some(capabilities);
Ok(updated)
}).await?;
Ok(())
}
/// Retrieves a list of all `AccountEntity` instances.
pub async fn list_all() -> BichonResult<Vec<AccountModel>> {
list_all_impl(DB_MANAGER.meta_db()).await
}
pub async fn minimal_list() -> BichonResult<Vec<MinimalAccount>> {
let result = list_all_impl(DB_MANAGER.meta_db())
.await?
.into_iter()
.filter(|a: &AccountModel| a.enabled)
.map(|account: AccountModel| MinimalAccount {
id: account.id,
email: account.email,
})
.collect::<Vec<MinimalAccount>>();
Ok(result)
}
pub async fn count() -> BichonResult<usize> {
count_by_unique_secondary_key_impl::<AccountModel>(DB_MANAGER.meta_db(), AccountV1Key::id)
.await
}
pub async fn paginate_list(
page: Option<u64>,
page_size: Option<u64>,
desc: Option<bool>,
) -> BichonResult<DataPage<AccountModel>> {
paginate_query_primary_scan_all_impl(DB_MANAGER.meta_db(), page, page_size, desc)
.await
.map(DataPage::from)
}
// This method applies the updates from the request to the old account entity
fn apply_update_fields(
old: &AccountModel,
request: AccountUpdateRequest,
) -> BichonResult<AccountModel> {
let mut new = old.clone();
if let Some(date_since) = request.date_since {
new.date_since = Some(date_since);
}
if let Some(folder_limit) = request.folder_limit {
new.folder_limit = Some(folder_limit);
}
if let Some(name) = &request.name {
new.name = Some(name.clone());
}
if matches!(old.account_type, AccountType::IMAP) {
if let Some(imap) = &request.imap {
let mut new_imap = imap.clone();
if let Some(password) = &new_imap.auth.password {
let encrypted_password = encrypt!(password)?;
new_imap.auth.password = Some(encrypted_password);
}
new.imap = Some(new_imap);
}
if let Some(folder_names) = request.sync_folders {
new.sync_folders = Some(folder_names);
}
if let Some(sync_interval_min) = &request.sync_interval_min {
new.sync_interval_min = Some(*sync_interval_min);
}
if let Some(use_proxy) = request.use_proxy {
new.use_proxy = Some(use_proxy);
}
}
if matches!(old.account_type, AccountType::NoSync) {
if let Some(email) = &request.email {
new.email = email.clone();
}
}
if let Some(enabled) = request.enabled {
new.enabled = enabled;
}
new.updated_at = utc_now!();
Ok(new)
}
}
+25
View File
@@ -0,0 +1,25 @@
//
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
pub mod dispatcher;
pub mod entity;
pub mod payload;
pub mod since;
pub mod state;
pub mod migration;
+172
View File
@@ -0,0 +1,172 @@
//
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use std::collections::BTreeSet;
use crate::modules::account::entity::ImapConfig;
use crate::modules::account::migration::{AccountModel, AccountType};
use crate::modules::account::since::DateSince;
use crate::modules::error::code::ErrorCode;
use crate::modules::error::BichonResult;
use crate::modules::token::AccountInfo;
use crate::{raise_error, validate_email};
use poem_openapi::Object;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize, Object)]
pub struct AccountCreateRequest {
#[oai(validator(custom = "crate::modules::common::validator::EmailValidator"))]
pub email: String,
pub name: Option<String>,
pub imap: Option<ImapConfig>,
pub enabled: bool,
pub date_since: Option<DateSince>,
pub account_type: AccountType,
#[oai(validator(minimum(value = "100")))]
pub folder_limit: Option<u32>,
#[oai(validator(minimum(value = "10"), maximum(value = "480")))]
pub sync_interval_min: Option<i64>,
pub use_proxy: Option<u64>,
}
impl AccountCreateRequest {
pub fn create_entity(self) -> BichonResult<AccountModel> {
if let Some(date_since) = self.date_since.as_ref() {
date_since.validate()?;
}
match self.account_type {
AccountType::IMAP => {
match &self.imap {
Some(imap) => Self::validate_request(imap, &self.email)?,
None => {
return Err(raise_error!(
"IMAP configuration is required for IMAP account type".into(),
ErrorCode::InvalidParameter
))
}
}
if self.sync_interval_min.is_none() {
return Err(raise_error!(
"`sync_interval_min` is required for IMAP account type".into(),
ErrorCode::InvalidParameter
));
}
}
AccountType::NoSync => {}
}
Ok(AccountModel::new(self)?)
}
fn validate_request(imap: &ImapConfig, email: &str) -> BichonResult<()> {
imap.auth
.validate()
.map_err(|e| raise_error!(e.to_owned(), ErrorCode::InvalidParameter))?;
validate_email!(email)?;
Ok(())
}
}
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize, Object)]
pub struct AccountUpdateRequest {
pub email: Option<String>,
/// Represents the account activation status.
///
/// If this value is `false`, all account-related resources will be unavailable
/// and any attempts to access them should return an error indicating the account
/// is inactive.
pub enabled: Option<bool>,
/// Display name for the account (optional)
pub name: Option<String>,
/// IMAP server configuration
pub imap: Option<ImapConfig>,
/// Controls initial synchronization time range
///
/// When dealing with large mailboxes, this restricts scanning to:
/// - Messages after specified starting point
/// - Or within sliding window
///
/// ### Use Cases
/// - Event-driven systems (only sync recent actionable emails)
/// - First-time sync optimization for large accounts
/// - Reducing server load during resyncs
pub date_since: Option<DateSince>,
/// Max emails to sync for this folder.
/// If not set, sync all emails.
/// otherwise sync up to `n` most recent emails (min 10).
#[oai(validator(minimum(value = "100")))]
pub folder_limit: Option<u32>,
/// Configuration for selective folder (mailbox/label) synchronization
///
/// - For IMAP/SMTP accounts:
/// Stores the mailbox names, since IMAP mailboxes do not have stable IDs.
/// Synchronization is keyed by the folder name.
///
/// - For Gmail API accounts:
/// A Gmail label is treated as a mailbox (model mapping).
/// Since label names can be easily changed, the stable `labelId` is recorded here
/// instead of the label name.
///
/// Defaults to standard folders (`INBOX`, `Sent`) if empty.
/// Modified folders will be automatically synced on the next update.
pub sync_folders: Option<Vec<String>>,
/// Incremental sync interval (seconds)
#[oai(validator(minimum(value = "10"), maximum(value = "480")))]
pub sync_interval_min: Option<i64>,
/// Optional proxy ID for establishing the connection to external APIs (e.g., Gmail, Outlook).
/// - If `None` or not provided, the client will connect directly to the API server.
/// - If `Some(proxy_id)`, the client will use the pre-configured proxy with the given ID for API requests.
pub use_proxy: Option<u64>,
}
impl AccountUpdateRequest {
pub fn validate_update_request(&self, account: &AccountModel) -> BichonResult<()> {
if let Some(date_since) = self.date_since.as_ref() {
date_since.validate()?;
}
if matches!(account.account_type, AccountType::IMAP) {
if let Some(mailboxes) = self.sync_folders.as_ref() {
if mailboxes.is_empty() {
return Err(raise_error!(
"Invalid configuration: 'sync_folders' cannot be empty. \
If you are modifying the subscription list, please provide at least one mailbox to subscribe to.".into(), ErrorCode::InvalidParameter
));
}
}
}
Ok(())
}
}
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize, Object)]
pub struct MinimalAccount {
pub id: u64,
pub email: String,
}
pub fn filter_accessible_accounts<'a>(
all_accounts: &'a [MinimalAccount],
allowed: &BTreeSet<AccountInfo>,
) -> Vec<MinimalAccount> {
all_accounts
.iter()
.filter(|acct| allowed.iter().any(|a| a.id == acct.id))
.cloned()
.collect()
}
+284
View File
@@ -0,0 +1,284 @@
//
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use crate::{
modules::error::{code::ErrorCode, BichonResult},
raise_error,
};
use chrono::{Datelike, Days, Local, Months, NaiveDate, Utc};
use poem_openapi::{Enum, Object};
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize, Object)]
pub struct DateSince {
/// Absolute date boundary in ISO 8601 format (YYYY-MM-DD)
///
/// ### Validation Rules
/// - Must match exact format `^\d{4}-\d{2}-\d{2}$`
/// - Date must be logically valid (e.g. no 2025-05-01)
///
/// ### Example
/// ```json
/// {
/// "fixed": "2025-05-01"
/// }
/// ```
#[oai(validator(pattern = r"^\d{4}-\d{2}-\d{2}$"))]
pub fixed: Option<String>,
/// Relative time period from current date
///
/// ### Constraints
/// - Value must be ≥ 1
/// - Units support day/month/year granularity
///
/// ### Example
/// ```json
/// {
/// "relative": {
/// "unit": "Days",
/// "value": 7
/// }
/// }
/// ```
pub relative: Option<RelativeDate>,
}
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize, Enum)]
pub enum Unit {
#[default]
Days,
Months,
Years,
}
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize, Object)]
pub struct RelativeDate {
/// The time unit to use for the offset (days, months, or years)
pub unit: Unit,
/// The quantity of time units to offset (must be a positive integer)
#[oai(validator(minimum(value = "1")))]
pub value: u32,
}
impl RelativeDate {
pub fn validate_date(&self) -> BichonResult<()> {
if self.value == 0 {
return Err(raise_error!(
"Value must be greater than 0".into(),
ErrorCode::InvalidParameter
));
}
let now = Local::now();
let date = match self.unit {
Unit::Days => now.checked_sub_days(Days::new(self.value as u64)),
Unit::Months => now.checked_sub_months(Months::new(self.value)),
Unit::Years => now.checked_sub_months(Months::new(self.value * 12)),
};
let date = date.ok_or_else(|| {
raise_error!(
"Invalid date: the calculated date is earlier than 1970 or an overflow occurred."
.into(),
ErrorCode::InvalidParameter
)
})?;
let naive_date = date.date_naive();
// Check if the date is before 1970
if naive_date.year() < 1970 {
return Err(raise_error!(
format!(
"Date cannot be earlier than 1970-01-01. Provided: '{}'",
naive_date
),
ErrorCode::InvalidParameter
));
}
Ok(())
}
fn compute_date(&self) -> BichonResult<chrono::DateTime<Local>> {
if self.value == 0 {
return Err(raise_error!(
"Value must be greater than 0".into(),
ErrorCode::InvalidParameter
));
}
let now = Local::now();
let date = match self.unit {
Unit::Days => now.checked_sub_days(Days::new(self.value as u64)),
Unit::Months => now.checked_sub_months(Months::new(self.value)),
Unit::Years => now.checked_sub_months(Months::new(self.value * 12)),
};
let date = date.ok_or_else(|| {
raise_error!(
"Invalid date: the calculated date is earlier than 1970 or an overflow occurred."
.into(),
ErrorCode::InvalidParameter
)
})?;
let naive_date = date.date_naive();
if naive_date.year() < 1970 {
return Err(raise_error!(
format!(
"Date cannot be earlier than 1970-01-01. Provided: '{}'",
naive_date
),
ErrorCode::InvalidParameter
));
}
Ok(date)
}
pub fn calculate_date(&self) -> BichonResult<String> {
let date = self.compute_date()?;
Ok(date.format("%d-%b-%Y").to_string())
}
}
impl DateSince {
pub fn validate(&self) -> BichonResult<()> {
match (&self.fixed, &self.relative) {
// If only `relative` is provided
(None, Some(r)) => {
r.validate_date()?;
}
// If only `fixed` is provided
(Some(fixed), None) => {
self.validate_fixed_date(fixed)?;
}
// If both or neither are provided
_ => {
return Err(raise_error!(
"Invalid input: You must provide either 'fixed' or 'relative', but not both."
.to_string(),
ErrorCode::InvalidParameter
));
}
}
Ok(())
}
fn validate_fixed_date(&self, fixed: &str) -> BichonResult<()> {
// Try to parse the input string as YYYY-MM-DD
let date = NaiveDate::parse_from_str(fixed, "%Y-%m-%d").map_err(|_| {
raise_error!(
format!(
"Invalid date format. Expected 'YYYY-MM-DD'. Example: '2024-11-19'. Provided: '{}'",
fixed
),
ErrorCode::InvalidParameter
)
})?;
let now = Utc::now().date_naive();
// Check if the date is in the future
if date >= now {
return Err(raise_error!(
format!(
"Date cannot be in the future. Provided: '{}', Today: '{}'",
fixed,
now.format("%Y-%m-%d")
),
ErrorCode::InvalidParameter
));
}
// Check if the date is before 1970
if date.year() < 1970 {
return Err(raise_error!(
format!(
"Date cannot be earlier than 1970-01-01. Provided: '{}'",
fixed
),
ErrorCode::InvalidParameter
));
}
Ok(())
}
fn format_user_date(&self, fixed: &str) -> BichonResult<String> {
let date = NaiveDate::parse_from_str(fixed, "%Y-%m-%d").map_err(|_| {
raise_error!(
format!(
"Invalid date format. Expected 'YYYY-MM-DD'. Example: '2024-11-19'. Provided: '{}'",
fixed
),
ErrorCode::InvalidParameter
)
})?;
// Format the date into "%d-%b-%Y" format
Ok(date.format("%d-%b-%Y").to_string())
}
pub fn since_date(&self) -> BichonResult<String> {
// Handle the case where only one of `fixed` or `relative` is provided
if let Some(r) = &self.relative {
// If `relative` is provided, calculate the date
r.calculate_date()
} else if let Some(f) = &self.fixed {
// If `fixed` is provided, format the date
self.format_user_date(f)
} else {
// If neither is provided, return an error
Err(raise_error!(
"You must provide either a 'fixed' or 'relative' date.".to_string(),
ErrorCode::InvalidParameter
))
}
}
}
#[cfg(test)]
mod test {
use crate::modules::account::since::{DateSince, RelativeDate, Unit};
#[test]
fn test1() {
let e = DateSince {
fixed: Some("2014-09-12".to_string()),
relative: None,
};
e.validate().unwrap();
println!("{}", e.since_date().unwrap());
let e = DateSince {
fixed: None,
relative: Some(RelativeDate {
unit: Unit::Days,
value: 1,
}),
};
e.validate().unwrap();
println!("{}", e.since_date().unwrap());
}
}
+340
View File
@@ -0,0 +1,340 @@
//
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use crate::{
modules::{
database::{async_find_impl, delete_impl, manager::DB_MANAGER, update_impl, upsert_impl},
error::{code::ErrorCode, BichonResult},
},
raise_error, utc_now,
};
use native_db::*;
use native_model::{native_model, Model};
use poem_openapi::Object;
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
const ERROR_COUNT_PER_ACCOUNT: usize = 30;
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize, Object)]
pub struct MailboxBatchProgress {
pub total_batches: u32,
pub current_batch: u32,
}
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize, Object)]
#[native_model(id = 2, version = 1)]
#[native_db]
pub struct AccountRunningState {
#[primary_key]
pub account_id: u64,
pub last_incremental_sync_start: i64,
pub last_incremental_sync_end: Option<i64>,
pub errors: Vec<AccountError>,
pub is_initial_sync_completed: bool,
pub progress: Option<BTreeMap<String, MailboxBatchProgress>>,
pub initial_sync_start_time: Option<i64>,
pub initial_sync_end_time: Option<i64>,
pub initial_sync_failed_time: Option<i64>,
}
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize, Object)]
pub struct AccountError {
pub error: String,
pub at: i64,
}
impl AccountRunningState {
pub async fn add(account_id: u64) -> BichonResult<()> {
let info = AccountRunningState {
account_id,
last_incremental_sync_start: 0,
last_incremental_sync_end: None,
errors: vec![],
is_initial_sync_completed: false,
progress: None,
initial_sync_start_time: None,
initial_sync_end_time: None,
initial_sync_failed_time: None,
};
upsert_impl(DB_MANAGER.envelope_db(), info).await
}
pub async fn get(account_id: u64) -> BichonResult<Option<AccountRunningState>> {
async_find_impl(DB_MANAGER.envelope_db(), account_id).await
}
async fn update_account_running_state(
account_id: u64,
updater: impl FnOnce(&AccountRunningState) -> BichonResult<AccountRunningState> + Send + 'static,
) -> BichonResult<()> {
update_impl(
DB_MANAGER.envelope_db(),
move |rw| {
rw.get()
.primary::<AccountRunningState>(account_id)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
.ok_or_else(|| {
raise_error!(
format!("Cannot find sync info of account={}", account_id),
ErrorCode::ResourceNotFound
)
})
},
updater,
)
.await?;
Ok(())
}
pub async fn delete(account_id: u64) -> BichonResult<()> {
if Self::get(account_id).await?.is_none() {
return Ok(());
}
delete_impl(DB_MANAGER.envelope_db(), move |rw| {
rw.get()
.primary::<AccountRunningState>(account_id)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
.ok_or_else(|| {
raise_error!(
format!(
"AccountRunningState '{}' not found during deletion process.",
account_id
),
ErrorCode::ResourceNotFound
)
})
})
.await
}
pub async fn set_initial_sync_start(account_id: u64) -> BichonResult<()> {
Self::update_account_running_state(account_id, move |current| {
let mut updated = current.clone();
updated.initial_sync_start_time = Some(utc_now!());
Ok(updated)
})
.await
}
pub async fn set_initial_sync_completed(account_id: u64) -> BichonResult<()> {
Self::update_account_running_state(account_id, move |current| {
let mut updated = current.clone();
updated.is_initial_sync_completed = true;
updated.initial_sync_end_time = Some(utc_now!());
Ok(updated)
})
.await
}
pub async fn set_initial_sync_failed(account_id: u64) -> BichonResult<()> {
Self::update_account_running_state(account_id, move |current| {
let mut updated = current.clone();
updated.initial_sync_failed_time = Some(utc_now!());
Ok(updated)
})
.await
}
pub async fn set_current_sync_batch_number(
account_id: u64,
syncing_folder: String,
batch_number: u32,
) -> BichonResult<()> {
Self::update_account_running_state(account_id, move |current| {
let mut updated = current.clone();
let mut progress_map = updated.progress.clone().unwrap_or_default();
let entry =
progress_map
.entry(syncing_folder.to_string())
.or_insert(MailboxBatchProgress {
total_batches: 0,
current_batch: 0,
});
entry.current_batch = batch_number;
updated.progress = Some(progress_map);
Ok(updated)
})
.await
}
pub async fn set_folder_initial_sync_completed(
account_id: u64,
syncing_folder: String,
) -> BichonResult<()> {
Self::update_account_running_state(account_id, move |current| {
let mut updated = current.clone();
let mut progress_map = updated.progress.clone().unwrap_or_default();
let entry =
progress_map
.entry(syncing_folder.to_string())
.or_insert(MailboxBatchProgress {
total_batches: 0,
current_batch: 0,
});
entry.current_batch = entry.total_batches;
updated.progress = Some(progress_map);
Ok(updated)
})
.await
}
pub async fn set_initial_current_syncing_folder(
account_id: u64,
current_syncing_folder: String,
total_sync_batches: u32,
) -> BichonResult<()> {
Self::update_account_running_state(account_id, move |current| {
let mut updated = current.clone();
let mut progress_map = updated.progress.clone().unwrap_or_default();
progress_map.insert(
current_syncing_folder.clone(),
MailboxBatchProgress {
total_batches: total_sync_batches,
current_batch: 0,
},
);
updated.progress = Some(progress_map);
Ok(updated)
})
.await
}
pub async fn set_incremental_sync_start(account_id: u64) -> BichonResult<()> {
Self::update_account_running_state(account_id, move |current| {
let mut updated = current.clone();
updated.last_incremental_sync_start = utc_now!();
updated.last_incremental_sync_end = None;
Ok(updated)
})
.await
}
pub async fn set_incremental_sync_end(account_id: u64) -> BichonResult<()> {
Self::update_account_running_state(account_id, move |current| {
let mut updated = current.clone();
updated.last_incremental_sync_end = Some(utc_now!());
Ok(updated)
})
.await
}
pub async fn append_error_message(account_id: u64, error: String) -> BichonResult<()> {
Self::update_account_running_state(account_id, move |current| {
let mut updated = current.clone();
updated.append_error_log(error);
Ok(updated)
})
.await
}
pub fn append_error_log(&mut self, error: String) {
let new_error = AccountError {
error,
at: utc_now!(),
};
self.errors.push(new_error);
if self.errors.len() > ERROR_COUNT_PER_ACCOUNT {
self.errors.remove(0);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_insert_single_error() {
let mut account_state = AccountRunningState {
account_id: 1000u64,
last_incremental_sync_start: 1000,
last_incremental_sync_end: Some(2000),
errors: Vec::new(),
..Default::default()
};
account_state.append_error_log(String::from("Error 1"));
assert_eq!(account_state.errors.len(), 1);
assert_eq!(account_state.errors[0].error, "Error 1");
}
#[test]
fn test_insert_multiple_errors() {
let mut account_state = AccountRunningState {
account_id: 1000u64,
last_incremental_sync_start: 1000,
last_incremental_sync_end: Some(2000),
errors: Vec::new(),
..Default::default()
};
for i in 1..=5 {
account_state.append_error_log(format!("Error {}", i));
}
assert_eq!(account_state.errors.len(), 5);
assert_eq!(account_state.errors[4].error, "Error 5");
}
#[test]
fn test_error_limit_exceeded() {
let mut account_state = AccountRunningState {
account_id: 1000u64,
last_incremental_sync_start: 1000,
last_incremental_sync_end: Some(2000),
errors: Vec::new(),
..Default::default()
};
for i in 1..=25 {
account_state.append_error_log(format!("Error {}", i));
}
// Should only keep the last 10 errors
assert_eq!(account_state.errors.len(), ERROR_COUNT_PER_ACCOUNT);
assert_eq!(account_state.errors[0].error, "Error 6");
assert_eq!(account_state.errors[19].error, "Error 25");
}
#[test]
fn test_insert_error_after_limit() {
let mut account_state = AccountRunningState {
account_id: 1000u64,
last_incremental_sync_start: 1000,
last_incremental_sync_end: Some(2000),
errors: Vec::new(),
..Default::default()
};
// Insert exactly 10 errors
for i in 1..=20 {
account_state.append_error_log(format!("Error {}", i));
}
// Insert one more error to exceed the limit
account_state.append_error_log(String::from("Error 21"));
assert_eq!(account_state.errors.len(), ERROR_COUNT_PER_ACCOUNT);
assert_eq!(account_state.errors[0].error, "Error 2"); // The first error is removed
assert_eq!(account_state.errors[19].error, "Error 21"); // The last inserted error
}
}
+75
View File
@@ -0,0 +1,75 @@
//
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use autoconfig::config::OAuth2Config as XOAuth2Config;
use poem_openapi::Object;
use serde::{Deserialize, Serialize};
use crate::modules::account::entity::Encryption;
#[derive(Debug, Clone, Default, Eq, PartialEq, Serialize, Deserialize, Object)]
pub struct ServerConfig {
/// server hostname or IP address
pub host: String,
/// server port number
pub port: u16,
/// Connection encryption method
pub encryption: Encryption,
}
impl ServerConfig {
pub fn new(host: String, port: u16, encryption: Encryption) -> Self {
Self {
host,
port,
encryption,
}
}
}
#[derive(Debug, Clone, Default, Eq, PartialEq, Serialize, Deserialize, Object)]
pub struct OAuth2Config {
/// The authorization server's issuer identifier URL
pub issuer: String,
/// List of scopes requested by the client
pub scope: Vec<String>,
/// URL of the authorization server's authorization endpoint
pub auth_url: String,
/// URL of the authorization server's token endpoint
pub token_url: String,
}
impl From<&XOAuth2Config> for OAuth2Config {
fn from(value: &XOAuth2Config) -> Self {
Self {
issuer: value.issuer().into(),
scope: value.scope().into_iter().map(Into::into).collect(),
auth_url: value.auth_url().into(),
token_url: value.token_url().into(),
}
}
}
#[derive(Debug, Clone, Default, Eq, PartialEq, Serialize, Deserialize, Object)]
pub struct MailServerConfig {
/// IMAP server configuration
pub imap: ServerConfig,
/// OAuth 2.0 client configuration parameters
pub oauth2: Option<OAuth2Config>,
}
+114
View File
@@ -0,0 +1,114 @@
//
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use crate::modules::autoconfig::entity::{MailServerConfig, ServerConfig};
use crate::modules::error::code::ErrorCode;
use crate::{
modules::{
account::entity::Encryption, autoconfig::CachedMailSettings, error::BichonResult,
},
raise_error,
};
use autoconfig::config::{Server, ServerType};
use email_address::EmailAddress;
use std::str::FromStr;
use tracing::error;
pub async fn resolve_autoconfig(
email: impl AsRef<str>,
) -> BichonResult<Option<MailServerConfig>> {
let email = email.as_ref();
let email_address = EmailAddress::from_str(email).map_err(|error| {
raise_error!(
format!("Invalid email address: {email:#?}. {error:#?}"),
ErrorCode::InvalidParameter
)
})?;
let domain = email_address.domain();
// try read local cache first
if let Some(cached_entity) = CachedMailSettings::get(domain).await? {
return Ok(Some(cached_entity.config));
}
let config = autoconfig::from_addr(email_address.email().as_ref())
.await
.map_err(|e| {
error!(email = %email, domain = %domain, error = ?e, "Autoconfig fetch failed");
raise_error!(
format!(
"Failed to fetch autoconfig for email '{}': {:#?}",
email_address.email(),
e
),
ErrorCode::AutoconfigFetchFailed
)
})?;
let imap_server = config
.email_provider()
.incoming_servers()
.into_iter()
.find(|s| matches!(s.server_type(), ServerType::Imap));
let imap_server = match imap_server {
Some(imap) => imap,
None => return Ok(None),
};
let get_encryption = |server: &Server| {
server
.security_type()
.map_or(Encryption::None, |encryption| match encryption {
autoconfig::config::SecurityType::Plain => Encryption::None,
autoconfig::config::SecurityType::Starttls => Encryption::StartTls,
autoconfig::config::SecurityType::Tls => Encryption::Ssl,
})
};
let get_port = |server: &Server, encryption: &Encryption, tls_port: u16, non_tls_port: u16| {
server.port().map_or_else(
|| match encryption {
Encryption::StartTls => tls_port,
_ => non_tls_port,
},
ToOwned::to_owned,
)
};
let get_hostname = |server: &Server, default_prefix: &str| {
server.hostname().map_or_else(
|| format!("{}.{}", default_prefix, domain),
ToOwned::to_owned,
)
};
let imap_encryption = get_encryption(imap_server);
let imap_config = ServerConfig::new(
get_hostname(imap_server, "imap"),
get_port(imap_server, &imap_encryption, 993, 143),
imap_encryption,
);
let result = MailServerConfig {
imap: imap_config,
oauth2: config.oauth2().map(|f| f.into()),
};
CachedMailSettings::add(domain.into(), result.clone()).await?;
Ok(Some(result))
}
+86
View File
@@ -0,0 +1,86 @@
//
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use crate::modules::database::manager::DB_MANAGER;
use crate::modules::database::{delete_impl, async_find_impl, upsert_impl};
use crate::modules::error::code::ErrorCode;
use crate::raise_error;
use crate::{
modules::autoconfig::entity::MailServerConfig, modules::error::BichonResult, utc_now,
};
use native_db::*;
use native_model::{native_model, Model};
use serde::{Deserialize, Serialize};
pub mod entity;
pub mod load;
#[cfg(test)]
mod tests;
const EXPIRE_TIME_MS: i64 = 30 * 24 * 60 * 60 * 1000;
#[derive(Clone, Debug, Default, PartialEq, Eq, Deserialize, Serialize)]
#[native_model(id = 3, version = 1)]
#[native_db]
pub struct CachedMailSettings {
#[primary_key]
pub domain: String,
pub config: MailServerConfig,
pub created_at: i64,
}
impl CachedMailSettings {
pub async fn add(domain: String, config: MailServerConfig) -> BichonResult<()> {
Self {
domain,
config,
created_at: utc_now!(),
}
.save()
.await
}
async fn save(&self) -> BichonResult<()> {
upsert_impl(DB_MANAGER.meta_db(), self.to_owned()).await
}
pub async fn get(domain: &str) -> BichonResult<Option<CachedMailSettings>> {
if let Some(found) =
async_find_impl::<CachedMailSettings>(DB_MANAGER.meta_db(), domain.to_string()).await?
{
if (utc_now!() - found.created_at) > EXPIRE_TIME_MS {
let domain = domain.to_string();
delete_impl(DB_MANAGER.meta_db(), |rw| {
rw.get()
.primary::<CachedMailSettings>(domain)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
.ok_or_else(|| {
raise_error!("auto config cache miss".into(), ErrorCode::InternalError)
})
})
.await?;
Ok(None)
} else {
Ok(Some(found))
}
} else {
Ok(None)
}
}
}
+24
View File
@@ -0,0 +1,24 @@
//
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
#[tokio::test]
async fn test() {
let config = autoconfig::from_addr("test@gmail.com").await.unwrap();
println!("{:#?}", config);
}
+207
View File
@@ -0,0 +1,207 @@
//
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use crate::{
decode_mailbox_name, encode_mailbox_name,
modules::{
database::{
batch_delete_impl, batch_insert_impl, batch_upsert_impl, filter_by_secondary_key_impl,
manager::DB_MANAGER,
},
error::{code::ErrorCode, BichonResult},
},
raise_error,
};
use async_imap::types::{Name, NameAttribute};
use itertools::Itertools;
use native_db::*;
use native_model::{native_model, Model};
use poem_openapi::{Enum, Object};
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize, Object)]
#[native_model(id = 1, version = 1)]
#[native_db]
pub struct MailBox {
/// The unique identifier for the mailbox
#[primary_key]
pub id: u64,
/// The ID of the account associated with the mailbox
#[secondary_key]
pub account_id: u64,
/// The unique, decoded, human-readable name of the mailbox (e.g., "INBOX", "Sent Items").
/// This is the decoded name as presented to users, derived from the IMAP server's mailbox name
/// (e.g., after decoding UTF-7 or other encodings per RFC 3501).
pub name: String,
/// Optional delimiter used to separate mailbox names in a hierarchy (e.g., "/" or ".").
/// Used in IMAP to structure nested mailboxes (e.g., "INBOX/Archive").
pub delimiter: Option<String>,
/// List of attributes associated with the mailbox (e.g., `\NoSelect`, `\Deleted`).
/// These indicate special properties, such as whether the mailbox can hold messages.
pub attributes: Vec<Attribute>,
/// The number of messages that currently exist in the mailbox.
pub exists: u32,
/// Optional number of unseen messages in the mailbox (i.e., messages without the `\Seen` flag).
pub unseen: Option<u32>,
/// The next unique identifier (UID) that will be assigned to a new message in the mailbox.
/// If `None`, the IMAP server has not provided this information.
pub uid_next: Option<u32>,
/// 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>,
}
impl MailBox {
pub fn encoded_name(&self) -> String {
encode_mailbox_name!(&self.name)
}
// pub async fn batch_delete(mailboxes: Vec<MailBox>) -> BichonResult<()> {
// batch_delete_impl(DB_MANAGER.envelope_db(), move |rw| {
// let mut to_deleted = Vec::new();
// for mailbox in mailboxes {
// let retrived = rw
// .get()
// .primary::<MailBox>(mailbox.id)
// .map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
// if let Some(retrived) = retrived {
// to_deleted.push(retrived);
// }
// }
// Ok(to_deleted)
// })
// .await?;
// Ok(())
// }
// pub async fn get(id: u64) -> RustMailerResult<MailBox> {
// let result = async_find_impl::<MailBox>(DB_MANAGER.envelope_db(), id).await?;
// Ok(result.ok_or_else(|| {
// raise_error!(
// format!("mailbox {} not found", id),
// ErrorCode::InternalError
// )
// })?)
// }
// pub async fn delete(id: u64) -> BichonResult<()> {
// delete_impl(DB_MANAGER.envelope_db(), move |rw| {
// rw.get()
// .primary::<MailBox>(id)
// .map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
// .ok_or_else(|| raise_error!("mailbox missing".into(), ErrorCode::InternalError))
// })
// .await
// }
pub async fn list_all(account_id: u64) -> BichonResult<Vec<MailBox>> {
filter_by_secondary_key_impl(DB_MANAGER.envelope_db(), MailBoxKey::account_id, account_id)
.await
}
pub async fn batch_insert(mailboxes: &[MailBox]) -> BichonResult<()> {
batch_insert_impl(DB_MANAGER.envelope_db(), mailboxes.to_vec()).await
}
pub async fn batch_upsert(mailboxes: &[MailBox]) -> BichonResult<()> {
batch_upsert_impl(DB_MANAGER.envelope_db(), mailboxes.to_vec()).await
}
pub async fn clean(account_id: u64) -> BichonResult<()> {
batch_delete_impl(DB_MANAGER.envelope_db(), move |rw| {
let mailboxes: Vec<MailBox> = rw
.scan()
.secondary::<MailBox>(MailBoxKey::account_id)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
.start_with(account_id)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
.try_collect()
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
Ok(mailboxes)
})
.await?;
Ok(())
}
}
#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize, Object)]
pub struct Attribute {
pub attr: AttributeEnum,
pub extension: Option<String>,
}
impl Attribute {
pub fn new(attr: AttributeEnum, extension: Option<String>) -> Self {
Self { attr, extension }
}
}
#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize, Enum)]
pub enum AttributeEnum {
NoInferiors,
NoSelect,
Marked,
Unmarked,
All,
Archive,
Drafts,
Flagged,
Junk,
Sent,
Trash,
Extension,
Unknown,
}
impl From<&Name> for MailBox {
fn from(value: &Name) -> Self {
let name = decode_mailbox_name!(value.name().to_string());
let delimiter = value.delimiter().map(|f| f.to_owned());
let attributes: Vec<Attribute> = value.attributes().iter().map(|na| na.into()).collect();
//The remaining parts will be supplemented during the examine_mailbox process.
MailBox {
name,
delimiter,
attributes,
..Default::default() //has_synced is initialized to false here
}
}
}
impl From<&NameAttribute<'_>> for Attribute {
fn from(value: &NameAttribute) -> Self {
match value {
NameAttribute::NoInferiors => Attribute::new(AttributeEnum::NoInferiors, None),
NameAttribute::NoSelect => Attribute::new(AttributeEnum::NoSelect, None),
NameAttribute::Marked => Attribute::new(AttributeEnum::Marked, None),
NameAttribute::Unmarked => Attribute::new(AttributeEnum::Unmarked, None),
NameAttribute::All => Attribute::new(AttributeEnum::All, None),
NameAttribute::Archive => Attribute::new(AttributeEnum::Archive, None),
NameAttribute::Drafts => Attribute::new(AttributeEnum::Drafts, None),
NameAttribute::Flagged => Attribute::new(AttributeEnum::Flagged, None),
NameAttribute::Junk => Attribute::new(AttributeEnum::Junk, None),
NameAttribute::Sent => Attribute::new(AttributeEnum::Sent, None),
NameAttribute::Trash => Attribute::new(AttributeEnum::Trash, None),
NameAttribute::Extension(s) => {
Attribute::new(AttributeEnum::Extension, Some(s.to_string()))
}
_ => Attribute::new(AttributeEnum::Unknown, None),
}
}
}
+66
View File
@@ -0,0 +1,66 @@
//
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use std::sync::LazyLock;
use crate::modules::{account::state::AccountRunningState, database::ModelsAdapter};
use ahash::{AHashMap, AHashSet};
use mailbox::MailBox;
use native_db::Models;
pub mod mailbox;
pub mod sync;
pub mod task;
pub static MAILBOX_MODELS: LazyLock<Models> = LazyLock::new(|| {
let mut adapter = ModelsAdapter::new();
adapter.register_model::<MailBox>();
adapter.register_model::<AccountRunningState>();
adapter.models
});
pub fn find_missing_mailboxes(
local_mailboxes: &[MailBox],
server_mailboxes: &[MailBox],
) -> Vec<MailBox> {
let local_names: AHashSet<_> = local_mailboxes.iter().map(|m| &m.name).collect();
server_mailboxes
.iter()
.filter(|m| !local_names.contains(&m.name))
.cloned()
.collect()
}
pub fn find_intersecting_mailboxes(
local_mailboxes: &[MailBox],
remote_mailboxes: &[MailBox],
) -> Vec<(MailBox, MailBox)> {
let local_map: AHashMap<_, _> = local_mailboxes
.iter()
.map(|m| (m.name.clone(), m.clone()))
.collect();
remote_mailboxes
.iter()
.filter_map(|m| {
local_map
.get(&m.name)
.map(|local_mailbox| (local_mailbox.clone(), m.clone()))
})
.collect()
}
+379
View File
@@ -0,0 +1,379 @@
//
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use crate::{
modules::{
account::{migration::AccountModel, state::AccountRunningState},
cache::{
imap::{
find_intersecting_mailboxes, find_missing_mailboxes,
mailbox::MailBox,
sync::rebuild::{rebuild_mailbox_cache, rebuild_mailbox_cache_since_date},
},
SEMAPHORE,
},
context::executors::MAIL_CONTEXT,
error::{code::ErrorCode, BichonError, BichonResult},
indexer::manager::ENVELOPE_INDEX_MANAGER,
},
raise_error,
};
use std::time::Instant;
use tracing::{debug, error, info, warn};
pub const BATCH_SIZE: u32 = 50;
pub async fn fetch_and_save_since_date(
account: &AccountModel,
date: &str,
mailbox: &MailBox,
) -> BichonResult<usize> {
let account_id = account.id;
let executor = MAIL_CONTEXT.imap(account_id).await?;
let uid_list = executor
.uid_search(&mailbox.encoded_name(), format!("SINCE {date}").as_str())
.await?;
let len = uid_list.len();
if len == 0 {
return Ok(0);
}
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 = uid_vec.split_off(len - limit as usize);
}
}
// let semaphore = Arc::new(Semaphore::new(5));
let uid_batches = generate_uid_sequence_hashset(uid_vec, BATCH_SIZE as usize, false);
AccountRunningState::set_initial_current_syncing_folder(
account_id,
mailbox.name.clone(),
uid_batches.len() as u32,
)
.await?;
for (index, batch) in uid_batches.into_iter().enumerate() {
AccountRunningState::set_current_sync_batch_number(
account_id,
mailbox.name.clone(),
(index + 1) as u32,
)
.await?;
let executor = MAIL_CONTEXT.imap(account_id).await?;
// Fetch metadata for the current batch of UIDs
executor
.uid_batch_retrieve_emails(account_id, mailbox.id, &batch, &mailbox.encoded_name())
.await?;
}
Ok(len)
}
pub async fn fetch_and_save_full_mailbox(
account: &AccountModel,
mailbox: &MailBox,
total: u32,
) -> BichonResult<usize> {
let mailbox_id = mailbox.id;
let account_id = account.id;
let folder_limit = account.folder_limit;
let total_to_fetch = match folder_limit {
Some(limit) if limit < total => total.min(limit.max(100)),
_ => total,
};
let page_size = if let Some(limit) = folder_limit {
limit.max(100).min(BATCH_SIZE as u32)
} else {
BATCH_SIZE as u32
};
let total_batches = total_to_fetch.div_ceil(page_size);
let desc = folder_limit.is_some();
let mut inserted_count = 0;
AccountRunningState::set_initial_current_syncing_folder(
account_id,
mailbox.name.clone(),
total_batches,
)
.await?;
info!(
"Starting full mailbox sync for '{}', total={}, limit={:?}, batches={}, desc={}",
mailbox.name, total, folder_limit, total_batches, desc
);
for page in 1..=total_batches {
AccountRunningState::set_current_sync_batch_number(account_id, mailbox.name.clone(), page)
.await?;
let executor = MAIL_CONTEXT.imap(account_id).await?;
let count = executor
.batch_retrieve_emails(
account_id,
mailbox_id,
page as u64,
page_size as u64,
&mailbox.encoded_name(),
desc,
)
.await?;
inserted_count += count;
info!(
"Batch insertion completed for mailbox: {}, current page: {}, inserted count: {}",
&mailbox.name, page, count
);
}
Ok(inserted_count)
}
/// # Example
///
/// ```rust
/// use std::collections::HashSet;
///
/// let mut uids = HashSet::new();
/// uids.extend([1, 2, 3, 5, 6, 7, 9, 10, 11, 15]);
///
/// let chunks = generate_uid_sequence_hashset(uids, 6, false);
/// assert_eq!(chunks, vec![
/// "1:3,5:7".to_string(),
/// "9:11,15".to_string()
/// ]);
/// ```
///
/// This splits the UIDs into chunks of 6, compresses each chunk into ranges,
/// and returns a vector like: `["1:3,5:7", "9:11,15"]`.
///
pub fn generate_uid_sequence_hashset(
unique_nums: Vec<u32>,
chunk_size: usize,
desc: bool,
) -> Vec<String> {
assert!(!unique_nums.is_empty());
// let mut nums: Vec<u32> = unique_nums.into_iter().collect();
// nums.sort();
let mut nums = unique_nums;
if desc {
nums.reverse();
}
let mut result = Vec::new();
for chunk in nums.chunks(chunk_size) {
let compressed = compress_uid_list(chunk.to_vec());
result.push(compressed);
}
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(",")
}
pub async fn reconcile_mailboxes(
account: &AccountModel,
remote_mailboxes: &[MailBox],
local_mailboxes: &[MailBox],
) -> BichonResult<()> {
let start_time = Instant::now();
let existing_mailboxes = find_intersecting_mailboxes(local_mailboxes, remote_mailboxes);
let account_id = account.id;
if !existing_mailboxes.is_empty() {
let mut mailboxes_to_update = Vec::with_capacity(existing_mailboxes.len());
for (local_mailbox, remote_mailbox) in &existing_mailboxes {
if local_mailbox.uid_validity != remote_mailbox.uid_validity {
if remote_mailbox.uid_validity.is_none() {
warn!(
"Account {}: Mailbox '{}' has invalid uid_validity (None). Skipping sync for this mailbox.",
account_id, local_mailbox.name
);
continue;
}
info!(
"Account {}: Mailbox '{}' detected with changed uid_validity (local: {:#?}, remote: {:#?}). \
The mailbox data may be invalid, resetting its envelopes and rebuilding the cache.",
account_id, local_mailbox.name, &local_mailbox.uid_validity, &remote_mailbox.uid_validity
);
match &account.date_since {
Some(date_since) => {
rebuild_mailbox_cache_since_date(
account,
local_mailbox.id,
date_since,
remote_mailbox,
)
.await?;
}
None => {
rebuild_mailbox_cache(account, local_mailbox, remote_mailbox).await?;
}
}
} else {
perform_incremental_sync(account, local_mailbox, remote_mailbox).await?;
}
if let Some(state) = AccountRunningState::get(account.id).await? {
if !state.is_initial_sync_completed {
AccountRunningState::set_folder_initial_sync_completed(
account_id,
local_mailbox.name.clone(),
)
.await?;
}
}
mailboxes_to_update.push(remote_mailbox.clone());
}
//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.
MailBox::batch_upsert(&mailboxes_to_update).await?;
}
debug!(
"Checked mailbox folders for account ID: {}. Compared local and server folders to identify changes. Elapsed time: {} seconds",
account.id,
start_time.elapsed().as_secs()
);
let missing_mailboxes = find_missing_mailboxes(local_mailboxes, remote_mailboxes);
if !missing_mailboxes.is_empty() {
MailBox::batch_insert(&missing_mailboxes).await?;
let mut handles = Vec::new();
for mailbox in &missing_mailboxes {
if mailbox.exists > 0 {
let account = account.clone();
let mailbox = mailbox.clone();
match SEMAPHORE.clone().acquire_owned().await {
Ok(permit) => {
let handle: tokio::task::JoinHandle<Result<(), BichonError>> =
tokio::spawn(async move {
let _permit = permit;
match &account.date_since {
Some(date_since) => {
rebuild_mailbox_cache_since_date(
&account, mailbox.id, date_since, &mailbox,
)
.await
}
None => {
rebuild_mailbox_cache(&account, &mailbox, &mailbox).await
}
}
});
handles.push(handle);
}
Err(err) => {
error!("Failed to acquire semaphore permit, error: {:#?}", err);
}
}
}
}
for task in handles {
match task.await {
Ok(Ok(())) => {}
Ok(Err(err)) => return Err(err),
Err(e) => return Err(raise_error!(format!("{:#?}", e), ErrorCode::InternalError)),
}
}
}
Ok(())
}
//only check new emails and sync
async fn perform_incremental_sync(
account: &AccountModel,
local_mailbox: &MailBox,
remote_mailbox: &MailBox,
) -> BichonResult<()> {
if remote_mailbox.exists > 0 {
let local_max_uid = ENVELOPE_INDEX_MANAGER
.get_max_uid(account.id, local_mailbox.id)
.await?;
match local_max_uid {
Some(max_uid) => {
let executor = MAIL_CONTEXT.imap(account.id).await?;
executor
.fetch_new_mail(account.id, local_mailbox, max_uid + 1)
.await?;
}
None => {
info!(
"No maximum UID found in index for mailbox, assuming local cache is missing."
);
match &account.date_since {
Some(date_since) => {
fetch_and_save_since_date(
account,
date_since.since_date()?.as_str(),
remote_mailbox,
)
.await?;
}
None => {
fetch_and_save_full_mailbox(account, remote_mailbox, remote_mailbox.exists)
.await?;
}
}
}
}
}
Ok(())
}
+113
View File
@@ -0,0 +1,113 @@
//
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use crate::{
modules::{
account::{
dispatcher::STATUS_DISPATCHER,
migration::{AccountModel, AccountType},
state::AccountRunningState,
},
cache::imap::mailbox::MailBox,
error::BichonResult,
},
utc_now,
};
use flow::reconcile_mailboxes;
use rebuild::{rebuild_cache, rebuild_cache_since_date};
use std::time::Instant;
use sync_folders::get_sync_folders;
use sync_type::{determine_sync_type, SyncType};
use tracing::debug;
pub mod flow;
pub mod rebuild;
pub mod sync_folders;
pub mod sync_type;
pub async fn execute_imap_sync(account: &AccountModel) -> BichonResult<()> {
assert_eq!(account.account_type, AccountType::IMAP);
let start_time = Instant::now();
let account_id = account.id;
let sync_type = determine_sync_type(account).await?;
if matches!(sync_type, SyncType::SkipSync) {
return Ok(());
}
let remote_mailboxes = get_sync_folders(account).await?;
if matches!(sync_type, SyncType::InitialSync) {
AccountRunningState::set_initial_sync_start(account_id).await?;
let result = match &account.date_since {
Some(date_since) => {
rebuild_cache_since_date(account, &remote_mailboxes, date_since).await
}
None => rebuild_cache(account, &remote_mailboxes).await,
};
match result {
Ok(_) => {
AccountRunningState::set_initial_sync_completed(account_id).await?;
}
Err(e) => {
STATUS_DISPATCHER
.append_error(
account_id,
format!("Initial sync failed for the account, error: {:#?}", e),
)
.await;
AccountRunningState::set_initial_sync_failed(account_id).await?;
}
}
return Ok(());
}
if let Some(state) = AccountRunningState::get(account_id).await? {
let now = utc_now!();
const COOLDOWN_MS: i64 = 60 * 1000;
let mut should_skip = false;
if let Some(time) = state.initial_sync_end_time {
if now - time < COOLDOWN_MS {
should_skip = true;
}
}
if let Some(time) = state.initial_sync_failed_time {
if now - time < COOLDOWN_MS {
should_skip = true;
}
}
if should_skip {
return Ok(());
}
}
AccountRunningState::set_incremental_sync_start(account.id).await?;
let local_mailboxes = MailBox::list_all(account_id).await?;
reconcile_mailboxes(account, &remote_mailboxes, &local_mailboxes).await?;
let elapsed_time = start_time.elapsed().as_secs();
debug!(
"Account{{{}}} Incremental sync completed: {} seconds elapsed.",
account.email, elapsed_time
);
if let Some(state) = AccountRunningState::get(account.id).await? {
if !state.is_initial_sync_completed {
AccountRunningState::set_initial_sync_completed(account_id).await?;
}
}
AccountRunningState::set_incremental_sync_end(account_id).await?;
Ok(())
}
+200
View File
@@ -0,0 +1,200 @@
//
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use crate::{
modules::{
account::{migration::AccountModel, since::DateSince},
cache::{
imap::{
mailbox::MailBox,
sync::flow::{fetch_and_save_full_mailbox, fetch_and_save_since_date},
},
SEMAPHORE,
},
error::{code::ErrorCode, BichonError, BichonResult},
indexer::manager::{EML_INDEX_MANAGER, ENVELOPE_INDEX_MANAGER},
},
raise_error,
};
use std::time::Instant;
use tracing::{error, info};
pub async fn rebuild_cache(
account: &AccountModel,
remote_mailboxes: &[MailBox],
) -> BichonResult<()> {
let start_time = Instant::now();
let mut total_inserted = 0;
MailBox::batch_insert(remote_mailboxes).await?;
let mut handles = Vec::new();
for mailbox in remote_mailboxes {
if mailbox.exists == 0 {
info!(
"Account {}: Mailbox '{}' on the remote server has no emails. Skipping fetch for this mailbox.",
account.id, &mailbox.name
);
continue;
}
let account = account.clone();
let mailbox = mailbox.clone();
match SEMAPHORE.clone().acquire_owned().await {
Ok(permit) => {
let handle: tokio::task::JoinHandle<Result<usize, BichonError>> =
tokio::spawn(async move {
let _permit = permit; // Ensure permit is released when task finishes
fetch_and_save_full_mailbox(&account, &mailbox, mailbox.exists).await
});
handles.push(handle);
}
Err(err) => {
error!("Failed to acquire semaphore permit, error: {:#?}", err);
}
}
}
for task in handles {
match task.await {
Ok(Ok(count)) => {
total_inserted += count;
}
Ok(Err(err)) => return Err(err),
Err(e) => return Err(raise_error!(format!("{:#?}", e), ErrorCode::InternalError)),
}
}
let elapsed_time = start_time.elapsed().as_secs();
info!(
"Rebuild account cache completed: {} envelopes inserted. {} secs elapsed. \
This is a full data fetch as there was no local cache data available.",
total_inserted, elapsed_time
);
Ok(())
}
pub async fn rebuild_cache_since_date(
account: &AccountModel,
remote_mailboxes: &[MailBox],
date_since: &DateSince,
) -> BichonResult<()> {
let start_time = Instant::now();
let mut total_inserted = 0;
let date = date_since.since_date()?;
MailBox::batch_insert(remote_mailboxes).await?;
let mut handles = Vec::new();
for mailbox in remote_mailboxes {
if mailbox.exists == 0 {
info!(
"Account {}: Mailbox '{}' on the remote server has no emails. Skipping fetch for this mailbox.",
account.id, &mailbox.name
);
continue;
}
let account = account.clone();
let mailbox = mailbox.clone();
let date = date.clone();
match SEMAPHORE.clone().acquire_owned().await {
Ok(permit) => {
let handle: tokio::task::JoinHandle<Result<usize, BichonError>> =
tokio::spawn(async move {
let _permit = permit; // Ensure permit is released when task finishes
fetch_and_save_since_date(&account, date.as_str(), &mailbox).await
});
handles.push(handle);
}
Err(err) => {
error!("Failed to acquire semaphore permit, error: {:#?}", err);
}
}
}
for task in handles {
match task.await {
Ok(Ok(count)) => {
total_inserted += count;
}
Ok(Err(err)) => return Err(err),
Err(e) => return Err(raise_error!(format!("{:#?}", e), ErrorCode::InternalError)),
}
}
let elapsed_time = start_time.elapsed().as_secs();
info!(
"Rebuild account cache completed: {} envelopes inserted. {} secs elapsed. \
Data fetched from server starting from the specified date: {}.",
total_inserted, elapsed_time, date
);
Ok(())
}
pub async fn rebuild_mailbox_cache(
account: &AccountModel,
local_mailbox: &MailBox,
remote_mailbox: &MailBox,
) -> BichonResult<()> {
ENVELOPE_INDEX_MANAGER
.delete_mailbox_envelopes(account.id, vec![local_mailbox.id])
.await?;
EML_INDEX_MANAGER
.delete_mailbox_envelopes(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.",
account.id,
&local_mailbox.name
);
return Ok(()); // Skip if the mailbox has no emails
}
let inserted_count =
fetch_and_save_full_mailbox(account, remote_mailbox, remote_mailbox.exists).await?;
info!(
"Account {}: Successfully rebuild mailbox cache, inserted {} envelopes for mailbox '{}'.",
account.id, inserted_count, &local_mailbox.name
);
Ok(())
}
pub async fn rebuild_mailbox_cache_since_date(
account: &AccountModel,
local_mailbox_id: u64,
date_since: &DateSince,
remote: &MailBox,
) -> BichonResult<()> {
ENVELOPE_INDEX_MANAGER
.delete_mailbox_envelopes(account.id, vec![local_mailbox_id])
.await?;
EML_INDEX_MANAGER
.delete_mailbox_envelopes(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.",
account.id,
&remote.name
);
return Ok(()); // Skip if the mailbox has no emails
}
let count =
fetch_and_save_since_date(account, date_since.since_date()?.as_str(), remote).await?;
info!(
"Account {}: Successfully rebuild mailbox cache, inserted {} envelopes for mailbox '{}'.",
account.id, count, &remote.name
);
Ok(())
}
+184
View File
@@ -0,0 +1,184 @@
//
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use std::collections::BTreeSet;
use crate::{
decode_mailbox_name,
modules::{
account::migration::{AccountModel, AccountType},
cache::imap::mailbox::{AttributeEnum, MailBox},
context::executors::MAIL_CONTEXT,
error::{code::ErrorCode, BichonResult},
mailbox::list::convert_names_to_mailboxes,
},
raise_error,
};
use async_imap::types::Name;
use tracing::{debug, info, warn};
pub async fn get_sync_folders(account: &AccountModel) -> BichonResult<Vec<MailBox>> {
assert_eq!(account.account_type, AccountType::IMAP);
let executor = MAIL_CONTEXT.imap(account.id).await?;
let names = executor.list_all_mailboxes().await?;
if names.is_empty() {
warn!(
"Account {}: No mailboxes returned from IMAP server.",
account.id
);
return Err(raise_error!(format!(
"No mailboxes returned from IMAP server for account {}. This is unexpected and may indicate an issue with the IMAP server.",
&account.id
), ErrorCode::ImapUnexpectedResult));
}
let mailboxes: Vec<(MailBox, Name)> = names.into_iter().map(|n| ((&n).into(), n)).collect();
for (mailbox, _) in &mailboxes {
debug!(
"[MAILBOX DEBUG] Account {}: mailbox='{}', attributes={:?}",
account.id, mailbox.name, mailbox.attributes
);
}
detect_mailbox_changes(
account,
mailboxes.iter().map(|(m, _)| m.name.clone()).collect(),
)
.await?;
let account = AccountModel::get(account.id).await?;
let subscribed = &account.sync_folders.unwrap_or_default();
let is_noselect = |mailbox: &MailBox| {
mailbox
.attributes
.iter()
.any(|attr| matches!(attr.attr, AttributeEnum::NoSelect))
};
let is_default_mailbox = |mailbox: &MailBox| {
mailbox.name.eq_ignore_ascii_case("INBOX")
|| mailbox
.attributes
.iter()
.any(|attr| matches!(attr.attr, AttributeEnum::Sent))
};
let mut matched_mailboxes: Vec<&Name> = if !subscribed.is_empty() {
mailboxes
.iter()
.filter(|(mailbox, _)| subscribed.contains(&mailbox.name) && !is_noselect(mailbox))
.map(|(_, name)| name)
.collect()
} else {
Vec::new()
};
if matched_mailboxes.is_empty() {
matched_mailboxes = mailboxes
.iter()
.filter(|(mailbox, _)| !is_noselect(mailbox) && is_default_mailbox(mailbox))
.map(|(_, name)| name)
.collect();
debug!(
"[MAILBOX DEBUG] Account {}: matched_mailboxes (default selection) = {:?}",
account.id,
matched_mailboxes
.iter()
.map(|n| decode_mailbox_name!(n.name().to_string()))
.collect::<Vec<_>>()
);
if !matched_mailboxes.is_empty() {
let sync_folders: Vec<String> = matched_mailboxes
.iter()
.map(|n| decode_mailbox_name!(n.name().to_string()))
.collect();
AccountModel::update_sync_folders(account.id, sync_folders).await?;
} else {
warn!(
"Account {}: No subscribed mailboxes found. This is unexpected — IMAP server should at least provide INBOX.",
account.id
);
return Err(raise_error!(format!(
"No subscribed mailboxes found for account {}. This is unexpected — IMAP server should at least provide INBOX.",
&account.id
), ErrorCode::ImapUnexpectedResult));
}
}
convert_names_to_mailboxes(account.id, matched_mailboxes).await
}
pub async fn detect_mailbox_changes(
account: &AccountModel,
all_names: BTreeSet<String>,
) -> BichonResult<()> {
if account.known_folders.is_none() {
// First time sync: just save without comparing
AccountModel::update_known_folders(account.id, all_names).await?;
return Ok(());
}
let known_folders = account.known_folders.clone().unwrap_or_default();
// Compute differences
let new_folders: Vec<String> = all_names.difference(&known_folders).cloned().collect();
let deleted_folders: Vec<String> = known_folders.difference(&all_names).cloned().collect();
let has_changes = !new_folders.is_empty() || !deleted_folders.is_empty();
let sync_folders = account.sync_folders.as_deref().unwrap_or_default();
// Handle deleted folders in sync_folders
if !deleted_folders.is_empty() {
// Check if any deleted folders are in sync_folders
let remaining_sync_folders: Vec<String> = sync_folders
.iter()
.filter(|folder| !deleted_folders.contains(folder))
.cloned()
.collect();
// If sync_folders changed, update them
if remaining_sync_folders.len() != sync_folders.len() {
let removed_count = sync_folders.len() - remaining_sync_folders.len();
info!(
"Account {}: Removed {} deleted folders from sync_folders",
account.id, removed_count
);
// Note: When all subscribed folders are deleted (remaining_sync_folders empty),
// the system's default behavior is to automatically fall back to syncing
// only the default folders (INBOX and Sent) in subsequent operations
AccountModel::update_sync_folders(account.id, remaining_sync_folders).await?;
}
info!(
"Account {}: Folders deleted: {:?}",
account.id, deleted_folders
);
}
// Fire events for new folders if needed
if !new_folders.is_empty() {
info!(
"Account {}: New folders detected: {:?}",
account.id, new_folders
);
}
// Update known folders only if there were changes
if has_changes {
AccountModel::update_known_folders(account.id, all_names).await?;
}
Ok(())
}
+68
View File
@@ -0,0 +1,68 @@
//
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use crate::{
modules::{
account::{migration::AccountModel, state::AccountRunningState},
error::BichonResult,
},
utc_now,
};
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum SyncType {
/// Initial sync, used when fetching all messages for the first time.
InitialSync,
/// Incremental synchronization, typically used for updates or fetching new data since the last sync.
IncrementalSync,
/// Skip synchronization, used when it's not yet time to perform the next sync.
SkipSync,
}
pub async fn determine_sync_type(account: &AccountModel) -> BichonResult<SyncType> {
Ok(match AccountRunningState::get(account.id).await? {
Some(info) => {
let now = utc_now!();
let incremental_sync = is_time_for_incremental_sync(
now,
info.last_incremental_sync_start,
account.sync_interval_min.unwrap(),
);
if incremental_sync {
SyncType::IncrementalSync
} else {
SyncType::SkipSync
}
}
None => {
AccountRunningState::add(account.id).await?;
SyncType::InitialSync
}
})
}
/// Check if it's time for an incremental sync based on the provided interval.
fn is_time_for_incremental_sync(
now: i64,
last_incremental_sync_at: i64,
sync_interval_min: i64,
) -> bool {
now - last_incremental_sync_at > (sync_interval_min * 60 * 1000)
}
+117
View File
@@ -0,0 +1,117 @@
//
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use crate::modules::account::entity::AuthType;
use crate::modules::cache::imap::sync::execute_imap_sync;
use crate::modules::common::periodic::{PeriodicTask, TaskHandle};
use crate::modules::oauth2::token::OAuth2AccessToken;
use crate::modules::{
account::{dispatcher::STATUS_DISPATCHER, migration::AccountModel},
error::BichonResult,
};
use crate::utc_now;
use dashmap::DashMap;
use std::sync::atomic::{AtomicI64, Ordering};
use std::{sync::LazyLock, time::Duration};
use tracing::{error, warn};
static _DESCRIPTION: &str = "This task periodically synchronizes mailbox data for a specified account, ensuring that all local data is up-to-date.";
const TASK_INTERVAL: Duration = Duration::from_secs(10);
pub static SYNC_TASKS: LazyLock<AccountSyncTask> = LazyLock::new(AccountSyncTask::new);
static LAST_WARN_TIME: AtomicI64 = AtomicI64::new(0);
const WARN_INTERVAL_MS: i64 = 600_000;
pub struct AccountSyncTask {
tasks: DashMap<u64, TaskHandle>,
}
impl AccountSyncTask {
pub fn new() -> Self {
Self {
tasks: DashMap::new(),
}
}
pub async fn start_account_sync_task(&self, account_id: u64, email: String) {
let task_name = format!("account-sync-task-{}-{}", account_id, &email);
let periodic_task = PeriodicTask::new(&task_name);
let task = move |param: Option<u64>| {
let account_id = param.unwrap();
Box::pin(async move {
let account = AccountModel::get(account_id).await.ok();
match account {
Some(account) => {
if !account.enabled {
let last = LAST_WARN_TIME.load(Ordering::Relaxed);
let now = utc_now!();
if now - last >= WARN_INTERVAL_MS {
LAST_WARN_TIME.store(now, Ordering::Relaxed);
warn!(
"Account {}: Sync aborted. Account is currently disabled.",
account_id
);
}
} else {
if let Some(imap) = &account.imap {
if let AuthType::OAuth2 = imap.auth.auth_type {
if OAuth2AccessToken::get(account.id).await?.is_none() {
if utc_now!() % 300_000 == 0 {
warn!("Account {}: Sync aborted. OAuth2 authorization not completed. Please visit the rustmailer admin page to authorize this account.", account_id);
}
return Ok(());
}
}
}
if let Err(e) = execute_imap_sync(&account).await {
STATUS_DISPATCHER
.append_error(
account_id,
format!("error in account sync task: {:#?}", e),
)
.await;
error!(
"Failed to synchronize mailbox data for '{}': {:?}",
account_id, e
)
}
}
}
None => {
error!(
"Account {}: Sync aborted. Account entity not found.",
account_id
);
}
}
Ok(())
})
};
let handler = periodic_task.start(task, Some(account_id), TASK_INTERVAL, true, true);
self.tasks.insert(account_id, handler);
}
pub async fn stop(&self, account_id: u64) -> BichonResult<()> {
if let Some((_, handler)) = self.tasks.remove(&account_id) {
handler.cancel().await;
} else {
warn!("No sync task found for account: {}", account_id);
}
Ok(())
}
}
+33
View File
@@ -0,0 +1,33 @@
//
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use crate::modules::settings::cli::SETTINGS;
use std::sync::{Arc, LazyLock};
use tokio::sync::Semaphore;
pub mod imap;
pub static SEMAPHORE: LazyLock<Arc<Semaphore>> = LazyLock::new(|| {
Arc::new(Semaphore::new(
SETTINGS
.bichon_sync_concurrency
.map(|c| c as usize)
.unwrap_or(num_cpus::get() * 2),
))
});
+232
View File
@@ -0,0 +1,232 @@
//
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use crate::{
modules::{
error::{code::ErrorCode, BichonResult},
settings::{cli::SETTINGS, system::SystemSetting},
token::{root::ROOT_TOKEN, AccessToken, AccountInfo},
utils::rate_limit::RATE_LIMITER_MANAGER,
},
raise_error,
};
use governor::clock::{Clock, QuantaClock};
use poem::{
web::{
headers::{authorization::Bearer, Authorization, HeaderMapExt},
RealIp,
},
Endpoint, FromRequest, Middleware, Request, RequestBody, Result,
};
use serde::Deserialize;
use std::{collections::BTreeSet, net::IpAddr, sync::Arc};
use super::create_api_error_response;
pub struct ApiGuard;
pub struct ApiGuardEndpoint<E> {
ep: E,
}
impl<E: Endpoint> Middleware<E> for ApiGuard {
type Output = ApiGuardEndpoint<E>;
fn transform(&self, ep: E) -> Self::Output {
ApiGuardEndpoint { ep }
}
}
#[derive(Deserialize)]
struct Param {
access_token: String,
}
impl<E: Endpoint> Endpoint for ApiGuardEndpoint<E> {
type Output = E::Output;
async fn call(&self, mut req: Request) -> Result<Self::Output> {
let context = authorize_access(&req).await?;
req.set_data(Arc::new(context));
self.ep.call(req).await
}
}
#[derive(Clone, Debug, Default)]
pub struct ClientContext {
pub ip_addr: Option<IpAddr>,
pub access_token: Option<AccessToken>,
pub is_root: bool,
}
impl ClientContext {
pub fn require_root(&self) -> BichonResult<()> {
if !SETTINGS.bichon_enable_access_token || self.is_root {
Ok(())
} else {
Err(raise_error!(
"Root access required".into(),
ErrorCode::PermissionDenied
))
}
}
pub fn require_authorized(&self) -> BichonResult<()> {
if !SETTINGS.bichon_enable_access_token || self.is_root || self.access_token.is_some() {
Ok(())
} else {
Err(raise_error!(
"Authorization required".into(),
ErrorCode::PermissionDenied
))
}
}
pub fn require_account_access(&self, account_id: u64) -> BichonResult<()> {
if !SETTINGS.bichon_enable_access_token || self.is_root {
return Ok(());
}
match &self.access_token {
Some(token) if token.can_access_account(account_id) => Ok(()),
_ => Err(raise_error!(format!(
"You do not have permission to access the requested email account (ID: {}). Please check your access rights or contact the administrator.",
account_id
), ErrorCode::PermissionDenied)),
}
}
pub fn accessible_accounts(&self) -> BichonResult<Option<&BTreeSet<AccountInfo>>> {
if !SETTINGS.bichon_enable_access_token || self.is_root {
Ok(None) // All accounts are accessible
} else {
match &self.access_token {
Some(token) => Ok(Some(&token.accounts)),
None => Err(raise_error!(
"Missing access token".into(),
ErrorCode::PermissionDenied
)),
}
}
}
}
impl<'a> FromRequest<'a> for ClientContext {
async fn from_request(req: &'a Request, _body: &mut RequestBody) -> Result<Self> {
extract_client_context(req).await
}
}
pub async fn extract_client_context(req: &Request) -> Result<ClientContext> {
if SETTINGS.bichon_enable_access_token {
let ip_addr = RealIp::from_request_without_body(req)
.await
.map_err(|_| {
create_api_error_response(
"Failed to parse client IP address",
ErrorCode::InvalidParameter,
)
})?
.0
.ok_or_else(|| {
create_api_error_response(
"Failed to parse client IP address",
ErrorCode::InvalidParameter,
)
})?;
// Extract access token from Bearer header or query params
let bearer = req
.headers()
.typed_get::<Authorization<Bearer>>()
.map(|auth| auth.0.token().to_string())
.or_else(|| req.params::<Param>().ok().map(|param| param.access_token));
let token = bearer.ok_or_else(|| {
create_api_error_response("Valid access token not found", ErrorCode::PermissionDenied)
})?;
// Check for root token
if let Ok(Some(root)) = SystemSetting::get(ROOT_TOKEN) {
if root.value == token {
return Ok(ClientContext {
ip_addr: Some(ip_addr),
access_token: None,
is_root: true,
});
}
}
// Validate and update access token
let validated_token = AccessToken::try_update_access_timestamp(&token)
.await
.map_err(|_| {
create_api_error_response("Invalid access token", ErrorCode::PermissionDenied)
})?;
return Ok(ClientContext {
ip_addr: Some(ip_addr),
access_token: Some(validated_token),
is_root: false,
});
}
Ok(Default::default())
}
pub async fn authorize_access(req: &Request) -> Result<ClientContext, poem::Error> {
let context = extract_client_context(&req).await?;
context.require_authorized().map_err(|error| {
create_api_error_response(&error.to_string(), ErrorCode::PermissionDenied)
})?;
if let Some(access_token) = &context.access_token {
if let Some(access_control) = &access_token.acl {
if let Some(ip_addr) = context.ip_addr {
if let Some(whitelist) = &access_control.ip_whitelist {
if !whitelist.contains(&ip_addr.to_string()) {
return Err(create_api_error_response(
&format!("IP {} not in whitelist", ip_addr),
ErrorCode::PermissionDenied,
));
}
}
}
if let Some(rate_limit) = &access_control.rate_limit {
if let Err(not_until) = RATE_LIMITER_MANAGER
.check(&access_token.token, rate_limit.clone())
.await
{
let wait_duration = not_until.wait_time_from(QuantaClock::default().now());
return Err(create_api_error_response(
&format!(
"Rate limit: {}/{}s. Retry after {}s",
rate_limit.quota,
rate_limit.interval,
wait_duration.as_secs()
),
ErrorCode::TooManyRequest,
));
}
}
}
}
Ok(context)
}
+47
View File
@@ -0,0 +1,47 @@
//
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use poem::{Endpoint, IntoResponse, Middleware, Request, Response, Result};
use crate::modules::error::handler::error_handler;
pub struct ErrorCapture;
pub struct ErrorCaptureEndpoint<E> {
ep: E,
}
impl<E: Endpoint> Middleware<E> for ErrorCapture {
type Output = ErrorCaptureEndpoint<E>;
fn transform(&self, ep: E) -> Self::Output {
ErrorCaptureEndpoint { ep }
}
}
impl<E: Endpoint> Endpoint for ErrorCaptureEndpoint<E> {
type Output = Response;
async fn call(&self, req: Request) -> Result<Self::Output> {
match self.ep.call(req).await {
Ok(response) => Ok(response.into_response()),
Err(error) => Ok(error_handler(error).await.into_response()),
}
}
}
+172
View File
@@ -0,0 +1,172 @@
//
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use std::{
num::NonZeroU32,
sync::{Arc, LazyLock},
time::Instant,
};
use governor::{
clock::{QuantaClock, QuantaInstant},
middleware::NoOpMiddleware,
state::InMemoryState,
Quota, RateLimiter,
};
use poem::{
http::header, web::RealIp, Endpoint, FromRequest, IntoResponse, Middleware, Request, Response,
Result,
};
use tracing::{error, info, warn, Instrument};
pub type GovRateLimiter = RateLimiter<
governor::state::NotKeyed,
InMemoryState,
QuantaClock,
NoOpMiddleware<QuantaInstant>,
>;
static RATE_LIMITER: LazyLock<LogRateLimiter> = LazyLock::new(LogRateLimiter::new);
pub struct LogRateLimiter {
limiter: Arc<GovRateLimiter>,
}
impl LogRateLimiter {
pub fn new() -> Self {
let quota = Quota::per_second(NonZeroU32::new(10).unwrap());
let limiter = RateLimiter::direct(quota);
Self {
limiter: Arc::new(limiter),
}
}
pub async fn should_log(&self, status: u16) -> bool {
let cost = match status {
500_u16.. => NonZeroU32::new(1).unwrap(), // ERROR
400_u16..=499_u16 => NonZeroU32::new(3).unwrap(), // WARN
_ => NonZeroU32::new(5).unwrap(), // INFO
};
self.limiter.check_n(cost).is_ok()
}
}
#[derive(Default)]
pub struct Tracing;
impl<E: Endpoint> Middleware<E> for Tracing {
type Output = TracingEndpoint<E>;
fn transform(&self, ep: E) -> Self::Output {
TracingEndpoint { inner: ep }
}
}
/// Endpoint for the `Tracing` middleware.
pub struct TracingEndpoint<E> {
inner: E,
}
impl<E: Endpoint> Endpoint for TracingEndpoint<E> {
type Output = Response;
async fn call(&self, req: Request) -> Result<Self::Output> {
let remote_addr = RealIp::from_request_without_body(&req)
.await
.ok()
.and_then(|real_ip| real_ip.0)
.map(|addr| addr.to_string())
.unwrap_or_else(|| req.remote_addr().to_string());
let method = req.method().clone();
let path = req.uri().path().to_string();
let query = req.uri().query().map(|q| q.to_string());
let referer = req
.headers()
.get(header::REFERER)
.and_then(|v| v.to_str().ok().map(|v| v.to_string()));
let content_length = req
.headers()
.get(header::CONTENT_LENGTH)
.and_then(|v| v.to_str().ok().map(|v| v.to_string()));
let span = tracing::info_span!(
"request",
remote_addr = %remote_addr,
method = %method,
path = %path,
query = ?query,
referer = ?referer,
//user_agent = ?user_agent,
// forwarded = ?forwarded,
content_length = ?content_length,
);
async move {
let now = Instant::now();
let res = self.inner.call(req).await;
let duration = now.elapsed();
match res {
Ok(resp) => {
let resp = resp.into_response();
let status = resp.status().as_u16();
log_response(status, duration).await;
Ok(resp)
}
Err(err) => {
let status = err.status().as_u16();
log_response(status, duration).await;
Err(err)
}
}
}
.instrument(span)
.await
}
}
#[inline]
async fn log_response(status: u16, duration: std::time::Duration) {
if RATE_LIMITER.should_log(status).await {
match status {
500.. => {
error!(
status = %status,
duration = ?duration,
"request completed with server error"
);
}
400..=499 => {
warn!(
status = %status,
duration = ?duration,
"request completed with client error"
);
}
_ => {
info!(
status = %status,
duration = ?duration,
"request completed successfully"
);
}
}
}
}
+233
View File
@@ -0,0 +1,233 @@
//
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use super::error::code::ErrorCode;
use super::error::BichonError;
use mail_parser::{Addr as ImapAddr, Address as ImapAddress};
use mail_send::mail_builder::headers::address::Address as SmtpAddress;
use mail_send::mail_builder::headers::address::EmailAddress as SmtpEmailAddress;
use poem::error::ResponseError;
use poem::Body;
use poem::{http::StatusCode, Error, Response};
use poem_openapi::Object;
use regex::Regex;
use serde::{Deserialize, Serialize};
use std::borrow::Cow;
use std::ops::Deref;
use tracing::error;
pub mod auth;
pub mod error;
pub mod log;
pub mod paginated;
pub mod periodic;
pub mod rustls;
pub mod signal;
pub mod timeout;
pub mod tls;
pub mod validator;
#[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize, Object)]
pub struct Addr {
/// The optional display name associated with the email address (e.g., "John Doe").
/// If `None`, no display name is specified.
pub name: Option<String>,
/// The optional email address (e.g., "john.doe@example.com").
/// If `None`, the address is unavailable, though typically at least one of `name` or `address` is provided.
pub address: Option<String>,
}
impl Addr {
pub fn parse(s: &str) -> Self {
let re = Regex::new(r#"(?:(?P<name>.*)\s*)?<(?P<email>[^<>]+)>"#).unwrap();
if let Some(caps) = re.captures(s) {
let name: Option<String> = caps.name("name").map(|m| m.as_str().trim().into());
let email: Option<String> = caps.name("email").map(|m| m.as_str().trim().into());
Addr {
name: if let Some(n) = name {
if n.is_empty() {
None
} else {
Some(n)
}
} else {
None
},
address: email,
}
} else {
let s_trimmed = s.trim();
Addr {
name: None,
address: if s_trimmed.is_empty() {
None
} else {
Some(s_trimmed.into())
},
}
}
}
}
impl std::fmt::Display for Addr {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match (&self.name, &self.address) {
(Some(name), Some(address)) => write!(f, "{} <{}>", name, address),
(None, Some(address)) => write!(f, "<{}>", address),
(Some(name), None) => write!(f, "{}", name),
(None, None) => write!(f, ""),
}
}
}
impl<'x> From<&ImapAddr<'x>> for Addr {
fn from(original: &ImapAddr<'x>) -> Self {
Addr {
name: original.name.as_ref().map(|s| s.to_string()),
address: original.address.as_ref().map(|s| s.to_string()),
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct AddrVec(pub Vec<Addr>);
impl Deref for AddrVec {
type Target = Vec<Addr>;
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl<'x> From<&ImapAddress<'x>> for AddrVec {
fn from(original: &ImapAddress<'x>) -> Self {
let vec = match original {
ImapAddress::List(addrs) => addrs.iter().map(Addr::from).collect(),
ImapAddress::Group(groups) => groups
.iter()
.flat_map(|group| group.addresses.iter().map(Addr::from))
.collect(),
};
AddrVec(vec)
}
}
impl<'x> From<SmtpEmailAddress<'x>> for Addr {
fn from(email: SmtpEmailAddress<'x>) -> Self {
Addr {
name: email.name.map(|n| n.into_owned()),
address: Some(email.email.into_owned()),
}
}
}
impl<'x> From<&SmtpAddress<'x>> for AddrVec {
fn from(address: &SmtpAddress<'x>) -> Self {
fn collect_addresses<'x>(address: &SmtpAddress<'x>, result: &mut Vec<Addr>) {
match address {
SmtpAddress::Address(email) => {
let addr = Addr::from(email.clone());
result.push(addr);
}
SmtpAddress::Group(group) => {
for addr in &group.addresses {
collect_addresses(addr, result);
}
}
SmtpAddress::List(list) => {
for addr in list {
collect_addresses(addr, result);
}
}
}
}
let mut addresses = Vec::new();
collect_addresses(address, &mut addresses);
AddrVec(addresses)
}
}
impl<'x> From<Addr> for SmtpAddress<'x> {
fn from(addr: Addr) -> Self {
SmtpAddress::Address(SmtpEmailAddress {
name: addr.name.map(Cow::Owned),
email: Cow::Owned(addr.address.unwrap_or_default()),
})
}
}
// #[derive(Serialize)]
// pub struct ErrorResponse {
// pub message: String,
// }
#[inline]
fn create_rust_mailer_error(message: &str, code: ErrorCode) -> BichonError {
BichonError::Generic {
message: message.into(),
location: snafu::Location::default(),
code,
}
}
#[inline]
pub fn create_api_error_response(message: &str, code: ErrorCode) -> Error {
let rust_mailer_error = create_rust_mailer_error(message, code);
rust_mailer_error.into()
}
impl ResponseError for BichonError {
fn status(&self) -> StatusCode {
match self {
BichonError::Generic {
message: _,
location: _,
code,
} => code.status(),
}
}
fn as_response(&self) -> Response
where
Self: std::error::Error + Send + Sync + 'static,
{
match self {
BichonError::Generic {
message,
location,
code,
} => {
error!(
error_code = *code as u32,
error_message = %message,
error_location = ?location
);
let body = Body::from_json(serde_json::json!({
"code": *code as u32,
"message": message.to_string(),
}))
.unwrap();
Response::builder().status(self.status()).body(body)
}
}
}
}
+72
View File
@@ -0,0 +1,72 @@
//
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use std::cmp::min;
use crate::{
modules::{
database::Paginated,
error::{code::ErrorCode, BichonResult},
},
raise_error,
};
pub fn paginate_vec<T: Clone>(
items: &Vec<T>,
page: Option<u64>,
page_size: Option<u64>,
) -> BichonResult<Paginated<T>> {
let total_items = items.len() as u64;
let (offset, total_pages) = match (page, page_size) {
(Some(p), Some(s)) if p > 0 && s > 0 => {
let offset = (p - 1) * s;
let total_pages = if total_items > 0 {
(total_items + s - 1) / s
} else {
0
};
(Some(offset), Some(total_pages))
}
(Some(0), _) | (_, Some(0)) => {
return Err(raise_error!(
"'page' and 'page_size' must be greater than 0.".into(),
ErrorCode::InvalidParameter
));
}
_ => (None, None),
};
let data = match offset {
Some(offset) if offset >= total_items => vec![],
Some(offset) => {
let end = min(offset + page_size.unwrap_or(total_items), total_items) as usize;
items[offset as usize..end].to_vec()
}
None => items.clone(),
};
Ok(Paginated::new(
page,
page_size,
total_items,
total_pages,
data,
))
}
+120
View File
@@ -0,0 +1,120 @@
//
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use crate::modules::{common::signal::SIGNAL_MANAGER, error::BichonResult};
use std::{future::Future, time::Duration};
use tokio::{sync::oneshot, time::MissedTickBehavior};
use tracing::{info, warn};
pub struct PeriodicTask {
name: String,
}
pub struct TaskHandle {
cancel_sender: Option<oneshot::Sender<()>>,
join_handle: tokio::task::JoinHandle<()>,
}
impl TaskHandle {
pub async fn cancel(self) {
if let Some(sender) = self.cancel_sender {
let _ = sender.send(());
}
let _ = self.join_handle.await;
}
}
impl PeriodicTask {
pub fn new(name: &str) -> Self {
Self {
name: name.to_owned(),
}
}
/// If `enable_cancel` is true, allows cancellation through TaskHandle::cancel
pub fn start<F, T>(
self,
task: T,
param: Option<u64>,
interval: Duration,
enable_cancel: bool,
run_immediately: bool,
) -> TaskHandle
where
T: Fn(Option<u64>) -> F + Send + Sync + 'static,
F: Future<Output = BichonResult<()>> + Send + 'static,
{
info!("Task '{}' started", &self.name);
let (cancel_sender_opt, cancel_receiver_opt) = if enable_cancel {
let (tx, rx) = oneshot::channel::<()>();
(Some(tx), Some(rx))
} else {
(None, None)
};
let name_clone = self.name.clone();
let join_handle = tokio::spawn(async move {
let mut interval = tokio::time::interval(interval);
interval.set_missed_tick_behavior(MissedTickBehavior::Skip);
let mut shutdown = SIGNAL_MANAGER.subscribe();
if !run_immediately {
interval.tick().await; // discard first immediate tick
}
let mut cancel_receiver = cancel_receiver_opt;
loop {
tokio::select! {
_ = interval.tick() => {
match task(param).await {
Ok(()) => {},
Err(e) => {
warn!("Task '{}' failed: {:?}", name_clone, e);
},
}
}
// only enabled if cancel_receiver is Some
_ = async {
if let Some(ref mut rx) = cancel_receiver {
rx.await.ok()
} else {
futures::future::pending().await
}
} => {
info!("Task '{}' received cancellation signal", name_clone);
break;
}
_ = shutdown.recv() => {
info!("Task '{}' shutting down due to shutdown signal", name_clone);
break;
}
}
}
info!("Task '{}' stopped", name_clone);
});
TaskHandle {
cancel_sender: cancel_sender_opt,
join_handle,
}
}
}
+41
View File
@@ -0,0 +1,41 @@
//
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use crate::{
modules::{
context::Initialize,
error::{code::ErrorCode, BichonResult},
},
raise_error,
};
pub struct RustMailerTls;
impl Initialize for RustMailerTls {
async fn initialize() -> BichonResult<()> {
rustls::crypto::CryptoProvider::install_default(rustls::crypto::ring::default_provider())
.map_err(|_| {
raise_error!(
"failed to set crypto provider".into(),
ErrorCode::InternalError
)
})
}
}
+55
View File
@@ -0,0 +1,55 @@
//
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use std::sync::LazyLock;
use crate::modules::{
context::Initialize, error::BichonResult, utils::shutdown::shutdown_signal,
};
use tokio::sync::broadcast;
pub static SIGNAL_MANAGER: LazyLock<SignalManager> = LazyLock::new(SignalManager::new);
pub struct SignalManager {
sender: broadcast::Sender<()>,
}
impl SignalManager {
pub fn new() -> Self {
let (sender, _) = broadcast::channel(1);
SignalManager { sender }
}
pub fn subscribe(&self) -> broadcast::Receiver<()> {
self.sender.subscribe()
}
}
impl Initialize for SignalManager {
async fn initialize() -> BichonResult<()> {
tokio::spawn({
async move {
shutdown_signal().await;
println!("\nSending shutdown signal...");
let _ = SIGNAL_MANAGER.sender.send(());
}
});
Ok(())
}
}
+74
View File
@@ -0,0 +1,74 @@
//
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use poem::{Endpoint, Middleware, Request, Result};
use std::time::Duration;
use tracing::error;
use crate::modules::error::code::ErrorCode;
use super::create_api_error_response;
pub const TIMEOUT_HEADER: &str = "X-RustMailer-Timeout-Seconds";
pub struct Timeout;
impl<E: Endpoint> Middleware<E> for Timeout {
type Output = TimeoutEndpoint<E>;
fn transform(&self, ep: E) -> Self::Output {
TimeoutEndpoint { ep }
}
}
pub struct TimeoutEndpoint<E> {
ep: E,
}
#[inline]
fn extract_timeout(req: &Request) -> Option<u64> {
if let Some(v) = req.header(TIMEOUT_HEADER) {
v.parse::<u64>().ok()
} else {
None
}
}
impl<E: Endpoint> Endpoint for TimeoutEndpoint<E> {
type Output = E::Output;
async fn call(&self, req: Request) -> Result<Self::Output> {
let timeout = extract_timeout(&req);
let seconds = timeout.unwrap_or(30).min(600);
match tokio::time::timeout(Duration::from_secs(seconds), self.ep.call(req)).await {
Ok(Ok(response)) => Ok(response), // If the request completes successfully
Ok(Err(e)) => Err(e), // If the request returns an error
Err(_) => {
error!("Request timed out after {} seconds", seconds);
Err(create_api_error_response(
&format!(
"Request timed out after {} seconds (timeout set via X-RustMailer-Timeout-Seconds header, max allowed: 600 seconds)",
seconds
),
ErrorCode::RequestTimeout,
))
}
}
}
}
+54
View File
@@ -0,0 +1,54 @@
//
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use poem::listener::{RustlsCertificate, RustlsConfig};
use crate::{
modules::{
error::{code::ErrorCode, BichonResult},
settings::dir::DATA_DIR_MANAGER,
},
raise_error,
};
pub fn rustls_config() -> BichonResult<RustlsConfig> {
let cert = std::fs::read_to_string(&DATA_DIR_MANAGER.tls_cert).map_err(|e| {
raise_error!(
format!(
"Failed to read TLS certificate: '{}' (error: {})",
DATA_DIR_MANAGER.tls_cert.display(),
e
),
ErrorCode::InternalError
)
})?;
let key = std::fs::read_to_string(&DATA_DIR_MANAGER.tls_key).map_err(|e| {
raise_error!(
format!(
"Failed to read TLS private key: '{}' (error: {})",
DATA_DIR_MANAGER.tls_key.display(),
e
),
ErrorCode::InternalError
)
})?;
let rustls_certificate = RustlsCertificate::new().cert(cert).key(key);
Ok(RustlsConfig::new().fallback(rustls_certificate))
}
+43
View File
@@ -0,0 +1,43 @@
//
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use std::{
fmt::{self, Display, Formatter},
str::FromStr,
};
use email_address::EmailAddress;
use poem_openapi::Validator;
pub struct EmailValidator;
impl Display for EmailValidator {
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
f.write_str("Not a valid email address")
}
}
impl Validator<String> for EmailValidator {
fn check(&self, value: &String) -> bool {
match EmailAddress::from_str(value) {
Ok(e) => &e.email() == value,
Err(_) => false,
}
}
}
+72
View File
@@ -0,0 +1,72 @@
//
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use crate::modules::{cache::imap::task::SYNC_TASKS, error::BichonResult};
use std::{sync::LazyLock, time::Duration};
use tokio::sync::mpsc;
use tracing::{error, info};
pub static SYNC_CONTROLLER: LazyLock<SyncController> = LazyLock::new(SyncController::new);
pub struct SyncController {
channel: mpsc::Sender<(u64, String)>, // Channel to trigger account sync by account ID
}
impl SyncController {
pub fn new() -> Self {
let (tx, mut rx) = mpsc::channel::<(u64, String)>(100);
tokio::spawn(async move {
while let Some((account_id, email)) = rx.recv().await {
match Self::start_syncer(account_id, email.clone()).await {
Ok(Some(_)) => {}
Ok(None) => {}
Err(err) => {
error!(
"Failed to prepare and start syncer of account {{{}-{}}}, error: {:#?}",
&account_id, &email, err
);
}
}
}
});
SyncController { channel: tx }
}
/// Trigger synchronization for a specific account
pub async fn trigger_start(&self, account_id: u64, email: String) {
if let Err(e) = self.channel.send((account_id, email)).await {
error!(
"Failed to trigger synchronization for account={{{}}}, error: {:?}",
account_id, e
);
}
}
async fn start_syncer(account_id: u64, email: String) -> BichonResult<Option<()>> {
info!(
"Account syncer starting for account: {}-{}.",
account_id, email
);
SYNC_TASKS.start_account_sync_task(account_id, email).await;
tokio::time::sleep(Duration::from_millis(100)).await;
Ok(Some(()))
}
}
+110
View File
@@ -0,0 +1,110 @@
//
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use crate::modules::context::Initialize;
use crate::modules::error::code::ErrorCode;
use crate::raise_error;
use crate::{
modules::{
account::migration::AccountModel,
context::controller::SYNC_CONTROLLER,
error::BichonResult,
imap::{executor::ImapExecutor, pool::build_imap_pool},
},
utc_now,
};
use dashmap::DashMap;
use std::sync::{Arc, LazyLock};
use tracing::info;
pub static MAIL_CONTEXT: LazyLock<EmailClientExecutors> =
LazyLock::new(EmailClientExecutors::new);
pub struct EmailClientExecutors {
start_at: i64,
imap: DashMap<u64, Arc<ImapExecutor>>,
}
impl Initialize for EmailClientExecutors {
async fn initialize() -> BichonResult<()> {
MAIL_CONTEXT.start_account_syncers().await
}
}
impl EmailClientExecutors {
pub fn new() -> Self {
Self {
start_at: utc_now!(),
imap: DashMap::new(),
}
}
pub fn uptime_ms(&self) -> i64 {
utc_now!() - self.start_at
}
pub async fn imap(&self, account_id: u64) -> BichonResult<Arc<ImapExecutor>> {
if let Some(executor) = self.imap.get(&account_id) {
return Ok(executor.value().clone());
}
let pool = build_imap_pool(account_id).await?;
let new_executor = Arc::new(ImapExecutor::new(pool));
match self.imap.try_entry(account_id) {
Some(dashmap::mapref::entry::Entry::Occupied(entry)) => Ok(entry.get().clone()),
Some(dashmap::mapref::entry::Entry::Vacant(entry)) => {
entry.insert(new_executor.clone());
Ok(new_executor)
}
None => Err(raise_error!(
"DashMap locked".into(),
ErrorCode::InternalError
)),
}
}
pub async fn clean_account(&self, account_id: u64) -> BichonResult<()> {
if self.imap.remove(&account_id).is_some() {
info!(account_id, "Closed IMAP pool for account");
}
Ok(())
}
pub async fn start_account_syncers(&self) -> BichonResult<()> {
let accounts = AccountModel::list_all().await?;
let active_accounts: Vec<AccountModel> =
accounts.into_iter().filter(|a| a.enabled).collect();
if active_accounts.is_empty() {
info!("No active accounts found for account initialization.");
return Ok(());
}
info!(
"System has {} active accounts to initialize.",
active_accounts.len()
);
for account in active_accounts {
SYNC_CONTROLLER
.trigger_start(account.id, account.email)
.await
}
Ok(())
}
}
+32
View File
@@ -0,0 +1,32 @@
//
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use crate::modules::error::BichonResult;
pub mod controller;
pub mod executors;
pub mod status;
pub trait Initialize {
async fn initialize() -> BichonResult<()>;
}
pub trait RustMailTask {
fn start();
}
+50
View File
@@ -0,0 +1,50 @@
//
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use crate::modules::context::executors::MAIL_CONTEXT;
use chrono::Local;
use poem_openapi::Object;
use serde::Deserialize;
use serde::Serialize;
use std::time::Duration;
use timeago::Formatter;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Object)]
pub struct BichonStatus {
/// The service uptime in milliseconds since it started.
pub uptime_ms: i64,
/// A human-readable string indicating the time elapsed since the service started (e.g., "2 hours ago").
pub timeago: String,
/// The timezone in which the service is operating (e.g., "UTC" or "Asia/Tokyo").
pub timezone: String,
/// The version of the RustMailer service currently running.
pub version: String,
}
impl BichonStatus {
pub fn get() -> Self {
Self {
uptime_ms: MAIL_CONTEXT.uptime_ms(),
timeago: Formatter::new()
.convert(Duration::from_millis(MAIL_CONTEXT.uptime_ms() as u64)),
timezone: Local::now().offset().to_string(),
version: env!("CARGO_PKG_VERSION").into(),
}
}
}
+109
View File
@@ -0,0 +1,109 @@
//
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use poem_openapi::Object;
use serde::{Deserialize, Serialize};
use tantivy::{schema::Value, TantivyDocument};
use crate::{
modules::{
account::migration::AccountModel,
error::{code::ErrorCode, BichonResult},
indexer::{manager::ENVELOPE_INDEX_MANAGER, schema::SchemaTools},
settings::dir::DATA_DIR_MANAGER,
utils::get_total_size,
},
raise_error,
};
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize, Object)]
pub struct DashboardStats {
pub account_count: usize, // Number of accounts
pub email_count: u64, // Total number of emails
pub total_size_bytes: u64, // Total size of all emails (in bytes)
pub storage_usage_bytes: u64, // Actual storage used (in bytes)
pub index_usage_bytes: u64, // Index storage size (in bytes)
pub recent_activity: Vec<TimeBucket>, // Email activity over recent days
pub top_senders: Vec<Group>, // Top 10 senders
pub top_accounts: Vec<Group>, // Top 10 accounts
pub with_attachment_count: u64, // Emails with attachments
pub without_attachment_count: u64, // Emails without attachments
pub top_largest_emails: Vec<LargestEmail>, // Top 10 largest emails
}
impl DashboardStats {
pub async fn get() -> BichonResult<Self> {
let mut stat = ENVELOPE_INDEX_MANAGER.get_dashboard_stats().await?;
stat.top_largest_emails = ENVELOPE_INDEX_MANAGER.top_10_largest_emails().await?;
stat.email_count = ENVELOPE_INDEX_MANAGER.total_emails()?;
stat.account_count = AccountModel::count().await?;
stat.storage_usage_bytes = get_total_size(&DATA_DIR_MANAGER.eml_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))?;
Ok(stat)
}
}
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize, Object)]
pub struct TimeBucket {
pub timestamp_ms: i64, // Timestamp in milliseconds
pub count: u64, // Number of emails in this time bucket
}
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize, Object)]
pub struct Group {
pub key: String,
pub count: u64, // Number of emails from this sender
}
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize, Object)]
pub struct LargestEmail {
pub subject: String, // Email subject
pub size_bytes: u64, // Email size in bytes
}
impl LargestEmail {
pub fn from_tantivy_doc(document: &TantivyDocument) -> BichonResult<Self> {
let fields = SchemaTools::envelope_fields();
let value = document.get_first(fields.f_size).ok_or_else(|| {
raise_error!(
"miss 'size' field in tantivy document".into(),
ErrorCode::InternalError
)
})?;
let size_bytes = value.as_u64().ok_or_else(|| {
raise_error!("'size' field is not a u64".into(), ErrorCode::InternalError)
})?;
let value = document.get_first(fields.f_subject).ok_or_else(|| {
raise_error!("'subject' field not found".into(), ErrorCode::InternalError)
})?;
let subject = value.as_str().map(|s| s.to_string()).ok_or_else(|| {
raise_error!(
"'subject' field is not a string".into(),
ErrorCode::InternalError
)
})?;
let envelope = LargestEmail {
subject,
size_bytes,
};
Ok(envelope)
}
}
+118
View File
@@ -0,0 +1,118 @@
//
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use crate::modules::cache::imap::MAILBOX_MODELS;
use crate::modules::error::{code::ErrorCode, BichonError};
use crate::modules::settings::cli::SETTINGS;
use crate::modules::settings::dir::DATA_DIR_MANAGER;
use crate::modules::{database::META_MODELS, error::BichonResult};
use crate::raise_error;
use native_db::{Builder, Database};
use std::sync::{Arc, LazyLock};
use tracing::info;
pub static DB_MANAGER: LazyLock<DatabaseManager> = LazyLock::new(DatabaseManager::new);
/// Metadata database instance
pub struct DatabaseManager {
meta_db: Arc<Database<'static>>,
/// Envelope database instance
envelope_db: Arc<Database<'static>>,
}
impl DatabaseManager {
fn new() -> Self {
let meta_db = Self::init_meta_database().expect("Failed to initialize metadata database");
let envelope_db =
Self::init_evenlope_database().expect("Failed to initialize evenlope database");
DatabaseManager {
meta_db,
envelope_db,
}
}
/// Get a reference to the metadata database
pub fn meta_db(&self) -> &Arc<Database<'static>> {
&self.meta_db
}
pub fn envelope_db(&self) -> &Arc<Database<'static>> {
&self.envelope_db
}
/// Initialize metadata database with a fixed or configured file path
fn init_meta_database() -> BichonResult<Arc<Database<'static>>> {
let mut database = Builder::new()
.set_cache_size(
SETTINGS
.bichon_metadata_cache_size
.unwrap_or(134217728)
.max(67108864),
) //default 128MB
.create(&META_MODELS, DATA_DIR_MANAGER.meta_db.clone())
.map_err(Self::handle_database_error)?;
let rw = database
.rw_transaction()
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
rw.commit()
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
database
.compact()
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
Ok(Arc::new(database))
}
fn init_evenlope_database() -> BichonResult<Arc<Database<'static>>> {
info!(
"Initializing envelope database at: {:?}",
&DATA_DIR_MANAGER.mailbox_db
);
let mut database = Builder::new()
.set_cache_size(
SETTINGS
.bichon_envelope_cache_size
.unwrap_or(1073741824)
.max(67108864),
) //default 1GB
.create(&MAILBOX_MODELS, DATA_DIR_MANAGER.mailbox_db.clone())
.map_err(Self::handle_database_error)?;
let rw = database
.rw_transaction()
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
rw.commit()
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
database
.compact()
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
Ok(Arc::new(database))
}
fn handle_database_error(error: native_db::db_type::Error) -> BichonError {
raise_error!(
format!("Failed to create database: {:?}", error),
ErrorCode::InternalError
)
}
}
+492
View File
@@ -0,0 +1,492 @@
//
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use crate::modules::account::migration::AccountV1;
use crate::modules::autoconfig::CachedMailSettings;
use crate::modules::error::code::ErrorCode;
use crate::modules::error::BichonResult;
use crate::modules::oauth2::entity::OAuth2;
use crate::modules::oauth2::pending::OAuth2PendingEntity;
use crate::modules::oauth2::token::OAuth2AccessToken;
use crate::modules::settings::proxy::Proxy;
use crate::modules::settings::system::SystemSetting;
use crate::modules::token::AccessToken;
use crate::raise_error;
use db_type::{KeyOptions, ToKeyDefinition};
use itertools::Itertools;
use native_db::*;
use serde::Serialize;
use std::sync::{Arc, LazyLock};
use transaction::RwTransaction;
pub mod manager;
pub static META_MODELS: LazyLock<Models> = LazyLock::new(|| {
let mut adapter = ModelsAdapter::new();
adapter.register_metadata_models();
adapter.models
});
pub struct ModelsAdapter {
pub models: Models,
}
impl ModelsAdapter {
pub fn new() -> Self {
ModelsAdapter {
models: Models::new(),
}
}
pub fn register_model<T: ToInput>(&mut self) {
self.models.define::<T>().expect("failed to define model ");
}
pub fn register_metadata_models(&mut self) {
self.register_model::<AccessToken>();
self.register_model::<SystemSetting>();
self.register_model::<CachedMailSettings>();
self.register_model::<AccountV1>();
self.register_model::<OAuth2>();
self.register_model::<OAuth2PendingEntity>();
self.register_model::<OAuth2AccessToken>();
self.register_model::<Proxy>();
}
}
pub async fn insert_impl<T: ToInput + Clone + Send + 'static>(
database: &Arc<Database<'static>>,
item: T,
) -> BichonResult<()> {
let db = database.clone();
tokio::task::spawn_blocking(move || {
let rw_transaction = db
.rw_transaction()
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
rw_transaction
.insert(item)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
rw_transaction
.commit()
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
Ok(())
})
.await
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
}
pub async fn batch_insert_impl<T: ToInput + Clone + Send + 'static>(
database: &Arc<Database<'static>>,
batch: Vec<T>,
) -> BichonResult<()> {
let db = database.clone();
tokio::task::spawn_blocking(move || {
let rw_transaction = db
.rw_transaction()
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
for item in batch {
rw_transaction
.insert(item)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
}
rw_transaction
.commit()
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
Ok(())
})
.await
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
}
pub async fn batch_upsert_impl<T: ToInput + Clone + Send + 'static>(
database: &Arc<Database<'static>>,
batch: Vec<T>,
) -> BichonResult<()> {
let db = database.clone();
tokio::task::spawn_blocking(move || {
let rw_transaction = db
.rw_transaction()
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
for item in batch {
rw_transaction
.upsert(item)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
}
rw_transaction
.commit()
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
Ok(())
})
.await
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
}
pub async fn upsert_impl<T: ToInput + Clone + Send + 'static>(
database: &Arc<Database<'static>>,
item: T,
) -> BichonResult<()> {
let db = database.clone();
tokio::task::spawn_blocking(move || {
let rw_transaction = db
.rw_transaction()
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
rw_transaction
.upsert(item)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
rw_transaction
.commit()
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
Ok(())
})
.await
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
}
pub async fn update_impl<T: ToInput + Clone + std::fmt::Debug + Send + 'static>(
database: &Arc<Database<'static>>,
current: impl FnOnce(&RwTransaction) -> BichonResult<T> + Send + 'static,
updated: impl FnOnce(&T) -> BichonResult<T> + Send + 'static,
) -> BichonResult<T> {
let db = database.clone();
tokio::task::spawn_blocking(move || {
let rw = db
.rw_transaction()
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
let current_item = current(&rw)?;
let updated_item = updated(&current_item)?;
rw.update(current_item.clone(), updated_item)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
rw.commit()
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
Ok(current_item)
})
.await
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
}
// pub async fn batch_update_impl<T: ToInput + Clone + std::fmt::Debug + Send + 'static>(
// database: &Arc<Database<'static>>,
// filter: impl FnOnce(&RwTransaction) -> RustMailerResult<Vec<T>> + Send + 'static,
// updated: impl FnOnce(&Vec<T>) -> RustMailerResult<Vec<(T, T)>> + Send + 'static,
// ) -> RustMailerResult<Vec<T>> {
// let db = database.clone();
// tokio::task::spawn_blocking(move || {
// let rw = db
// .rw_transaction()
// .map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
// let targets = filter(&rw)?;
// let tuples = updated(&targets)?;
// for (old, updated) in tuples {
// rw.update(old, updated)
// .map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
// }
// rw.commit()
// .map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
// Ok(targets)
// })
// .await
// .map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
// }
pub async fn async_find_impl<T: ToInput + Clone + Send + 'static>(
database: &Arc<Database<'static>>,
key: impl ToKey + Send + 'static,
) -> BichonResult<Option<T>> {
let db = database.clone();
tokio::task::spawn_blocking(move || {
let r_transaction = db
.r_transaction()
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
let entity: Option<T> = r_transaction
.get()
.primary(key)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
Ok(entity)
})
.await
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
}
pub fn find_impl<T: ToInput + Clone + Send + 'static>(
database: &Arc<Database<'static>>,
key: &str,
) -> BichonResult<Option<T>> {
let db = database.clone();
let r_transaction = db
.r_transaction()
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
let entity: Option<T> = r_transaction
.get()
.primary(key)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
Ok(entity)
}
pub async fn delete_impl<T: ToInput + Clone + Send + 'static>(
database: &Arc<Database<'static>>,
delete: impl FnOnce(&RwTransaction) -> BichonResult<T> + Send + 'static,
) -> BichonResult<()> {
let db = database.clone();
tokio::task::spawn_blocking(move || {
let rw_transaction = db
.rw_transaction()
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
let to_delete = delete(&rw_transaction)?;
rw_transaction
.remove::<T>(to_delete)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
rw_transaction
.commit()
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
Ok(())
})
.await
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
}
pub async fn batch_delete_impl<T: ToInput + Clone + Send + 'static>(
database: &Arc<Database<'static>>,
delete: impl FnOnce(&RwTransaction) -> BichonResult<Vec<T>> + Send + 'static,
) -> BichonResult<usize> {
let db = database.clone();
tokio::task::spawn_blocking(move || {
let rw_transaction = db
.rw_transaction()
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
let to_delete = delete(&rw_transaction)?;
let delete_count = to_delete.len();
for item in to_delete {
rw_transaction
.remove(item)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
}
rw_transaction
.commit()
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
Ok(delete_count)
})
.await
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
}
pub async fn list_all_impl<T: ToInput + Clone + Send + 'static>(
database: &Arc<Database<'static>>,
) -> BichonResult<Vec<T>> {
let db = database.clone();
tokio::task::spawn_blocking(move || {
let r_transaction = db
.r_transaction()
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
let entities: Vec<T> = r_transaction
.scan()
.primary()
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
.all()
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
.try_collect()
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
Ok(entities)
})
.await
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
}
// For tables with a creation timestamp, place the creation time at the front of the primary key.
// This allows sorting by time, as the data is stored in dictionary order based on the primary key.
// If reverse sorting by time is needed, the iterator can be reversed.
pub async fn paginate_query_primary_scan_all_impl<
T: ToInput + Serialize + std::fmt::Debug + std::marker::Unpin + Send + Sync + 'static,
>(
database: &Arc<Database<'static>>,
page: Option<u64>,
page_size: Option<u64>,
desc: Option<bool>,
) -> BichonResult<Paginated<T>> {
let db = database.clone();
tokio::task::spawn_blocking(move || {
let r_transaction = db
.r_transaction()
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
let total_items = r_transaction
.len()
.primary::<T>()
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
// Validate page and page_size
let (offset, total_pages) = if let (Some(p), Some(s)) = (page, page_size) {
if p == 0 || s == 0 {
return Err(raise_error!(
"'page' and 'page_size' must be greater than 0.".into(),
ErrorCode::InvalidParameter
));
}
let offset = (p - 1) * s;
let total_pages = if total_items > 0 {
(total_items as f64 / s as f64).ceil() as u64
} else {
0
};
(Some(offset), Some(total_pages))
} else {
(None, None)
};
// Handle empty result early
if let Some(offset) = offset {
if offset >= total_items {
return Ok(Paginated::new(
page,
page_size,
total_items,
total_pages,
vec![],
));
}
}
let scan = r_transaction
.scan()
.primary()
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
let iter = scan
.all()
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
// Collect items based on the reverse flag and pagination
let items: Vec<T> = match desc {
Some(true) => iter
.rev()
.skip(offset.unwrap_or(0) as usize)
.take(page_size.unwrap_or(total_items) as usize)
.try_collect()
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?,
_ => iter
.skip(offset.unwrap_or(0) as usize)
.take(page_size.unwrap_or(total_items) as usize)
.try_collect()
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?,
};
Ok(Paginated::new(
page,
page_size,
total_items,
total_pages,
items,
))
})
.await
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
}
pub async fn filter_by_secondary_key_impl<T: ToInput + Clone + Send + 'static>(
database: &Arc<Database<'static>>,
key_def: impl ToKeyDefinition<KeyOptions> + Send + 'static,
start_with: impl ToKey + Send + 'static,
) -> BichonResult<Vec<T>> {
let db = database.clone();
tokio::task::spawn_blocking(move || {
let r_transaction = db
.r_transaction()
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
let entities: Vec<T> = r_transaction
.scan()
.secondary(key_def)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
.start_with(start_with)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
.try_collect()
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
Ok(entities)
})
.await
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
}
pub async fn count_by_unique_secondary_key_impl<T: ToInput + Clone + Send + 'static>(
database: &Arc<Database<'static>>,
key_def: impl ToKeyDefinition<KeyOptions> + Send + 'static,
) -> BichonResult<usize> {
let db = database.clone();
tokio::task::spawn_blocking(move || {
let r_transaction = db
.r_transaction()
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
let count = r_transaction
.scan()
.secondary::<T>(key_def)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
.all()
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
.count();
Ok(count)
})
.await
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
}
pub async fn secondary_find_impl<T: ToInput + Clone + Send + 'static>(
database: &Arc<Database<'static>>,
key_def: impl ToKeyDefinition<KeyOptions> + Send + 'static,
key: impl ToKey + Send + 'static,
) -> BichonResult<Option<T>> {
let db = database.clone();
tokio::task::spawn_blocking(move || {
let r_transaction = db
.r_transaction()
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
let entities: Option<T> = r_transaction
.get()
.secondary(key_def, key)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
Ok(entities)
})
.await
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
}
#[derive(Debug)]
pub struct Paginated<T> {
pub page: Option<u64>,
pub page_size: Option<u64>,
pub total_items: u64,
pub total_pages: Option<u64>,
pub items: Vec<T>,
}
impl<T> Paginated<T> {
pub fn new(
page: Option<u64>,
page_size: Option<u64>,
total_items: u64,
total_pages: Option<u64>,
items: Vec<T>,
) -> Self {
Paginated {
page,
page_size,
total_items,
total_pages,
items,
}
}
}
+146
View File
@@ -0,0 +1,146 @@
//
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use crate::modules::common::AddrVec;
use crate::modules::error::code::ErrorCode;
use crate::modules::error::BichonResult;
use crate::modules::utils::create_hash;
use crate::{calculate_hash, raise_error, utc_now};
use crate::{id, modules::indexer::envelope::Envelope};
use async_imap::types::Fetch;
use html2text::from_read;
use mail_parser::{Message, MessageParser, MimeHeaders};
pub fn extract_envelope(fetch: &Fetch, account_id: u64, mailbox_id: u64) -> BichonResult<Envelope> {
let internal_date = fetch
.internal_date()
.map(|d| d.timestamp_millis())
.unwrap_or(0);
let uid = fetch.uid.unwrap_or(0);
let size = fetch.size.unwrap_or(0);
let body = fetch
.body()
.ok_or_else(|| raise_error!("No body available".into(), ErrorCode::InternalError))?;
let message = MessageParser::new().parse(body).ok_or_else(|| {
raise_error!(
"Email header parse result is not available".into(),
ErrorCode::InternalError
)
})?;
let text = if let Some(text) = message.body_text(0).map(|cow| cow.into_owned()) {
text
} else if let Some(html) = message.body_html(0).map(|cow| cow.into_owned()) {
from_read(html.as_bytes(), 0)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
} else {
String::new()
};
let message_id = message
.message_id()
.map(String::from)
.unwrap_or(generate_message_id());
let in_reply_to = message.in_reply_to().as_text().map(String::from);
let references = extract_references(&message);
let thread_id = compute_thread_id(in_reply_to, references, &message_id);
let subject = message.subject().map(String::from).unwrap_or("".into());
let date = message.date().map(|d| d.to_timestamp() * 1000).unwrap_or(0);
let bcc: Option<Vec<String>> = message.bcc().map(|addr| {
AddrVec::from(addr)
.0
.into_iter()
.filter_map(|a| a.address)
.collect()
});
let cc: Option<Vec<String>> = message.cc().map(|addr| {
AddrVec::from(addr)
.0
.into_iter()
.filter_map(|a| a.address)
.collect()
});
let to: Option<Vec<String>> = message.to().map(|addr| {
AddrVec::from(addr)
.0
.into_iter()
.filter_map(|a| a.address)
.collect()
});
let from = message
.from()
.and_then(|addr| AddrVec::from(addr).0.into_iter().next())
.and_then(|add| add.address)
.unwrap_or_else(|| "unknown".to_string());
let attachments: Vec<String> = message
.attachments()
.filter_map(|att| att.attachment_name())
.map(|name| name.to_string())
.collect();
let envelope = Envelope {
id: create_hash(account_id, &message_id),
message_id,
account_id,
mailbox_id,
uid,
subject,
text,
from,
to: to.unwrap_or_default(),
cc: cc.unwrap_or_default(),
bcc: bcc.unwrap_or_default(),
date,
internal_date,
size,
thread_id,
attachments,
tags: None,
};
Ok(envelope)
}
pub fn compute_thread_id(
in_reply_to: Option<String>,
references: Option<Vec<String>>,
message_id: &str,
) -> u64 {
if in_reply_to.is_some() && references.as_ref().map_or(false, |r| !r.is_empty()) {
return calculate_hash!(&references.as_ref().unwrap()[0]);
}
calculate_hash!(message_id)
}
pub fn generate_message_id() -> String {
let ts = utc_now!();
let pid = std::process::id();
format!("<{:016x}.{}.{}@{}>", id!(128), ts, pid, "bichon")
}
fn extract_references(message: &Message<'_>) -> Option<Vec<String>> {
match message.references() {
mail_parser::HeaderValue::Text(cow) => Some(vec![cow.to_string()]),
mail_parser::HeaderValue::TextList(vec) => {
Some(vec.iter().map(|cow| cow.to_string()).collect())
}
_ => None,
}
}
+20
View File
@@ -0,0 +1,20 @@
//
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
pub mod extractor;
+86
View File
@@ -0,0 +1,86 @@
//
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use poem::http::StatusCode;
use poem_openapi::Enum;
#[derive(Copy, Clone, Debug, Enum, Eq, PartialEq)]
#[repr(u32)]
pub enum ErrorCode {
// Client-side errors (1000010999)
InvalidParameter = 10000,
MissingConfiguration = 10020,
Incompatible = 10030,
PayloadTooLarge = 10070,
RequestTimeout = 10080,
MethodNotAllowed = 10090,
// Authentication and authorization errors (2000020999)
PermissionDenied = 20000,
AccountDisabled = 20010,
OAuth2ItemDisabled = 20050,
MissingRefreshToken = 20060,
// Resource errors (3000030999)
ResourceNotFound = 30000,
TooManyRequest = 30020,
// Network connection errors (4000040999)
NetworkError = 40000,
ConnectionTimeout = 40010,
ConnectionPoolTimeout = 40020,
HttpResponseError = 40030,
// Mail service errors (5000050999)
ImapCommandFailed = 50000,
ImapAuthenticationFailed = 50010,
ImapUnexpectedResult = 50020,
AutoconfigFetchFailed = 50060,
// Internal system errors (7000070999)
InternalError = 70000,
UnhandledPoemError = 70010,
}
impl ErrorCode {
pub fn status(&self) -> StatusCode {
match self {
ErrorCode::InvalidParameter
| ErrorCode::MissingConfiguration
| ErrorCode::Incompatible => StatusCode::BAD_REQUEST,
ErrorCode::PermissionDenied => StatusCode::UNAUTHORIZED,
ErrorCode::AccountDisabled | ErrorCode::OAuth2ItemDisabled => StatusCode::FORBIDDEN,
ErrorCode::ResourceNotFound => StatusCode::NOT_FOUND,
ErrorCode::RequestTimeout => StatusCode::REQUEST_TIMEOUT,
ErrorCode::PayloadTooLarge => StatusCode::PAYLOAD_TOO_LARGE,
ErrorCode::TooManyRequest => StatusCode::TOO_MANY_REQUESTS,
ErrorCode::InternalError
| ErrorCode::AutoconfigFetchFailed
| ErrorCode::ImapCommandFailed
| ErrorCode::ImapUnexpectedResult
| ErrorCode::HttpResponseError
| ErrorCode::ImapAuthenticationFailed
| ErrorCode::MissingRefreshToken
| ErrorCode::NetworkError
| ErrorCode::ConnectionTimeout
| ErrorCode::ConnectionPoolTimeout
| ErrorCode::UnhandledPoemError => StatusCode::INTERNAL_SERVER_ERROR,
ErrorCode::MethodNotAllowed => StatusCode::METHOD_NOT_ALLOWED,
}
}
}
+76
View File
@@ -0,0 +1,76 @@
//
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use crate::modules::error::{code::ErrorCode, ApiError, ApiErrorResponse, BichonError};
use poem::IntoResponse;
use poem_openapi::payload::Json;
pub async fn error_handler(error: poem::Error) -> impl poem::IntoResponse {
if error.is::<BichonError>() {
return error.into_response();
}
let error_mapping = [
// Poem errors
(
error.is::<poem::error::NotFoundError>(),
ErrorCode::ResourceNotFound,
),
(
error.is::<poem::error::ParsePathError>()
|| error.is::<poem::error::ParseTypedHeaderError>()
|| error.is::<poem::error::ParseQueryError>()
|| error.is::<poem::error::ParseJsonError>()
|| error.is::<poem_openapi::error::ParseRequestPayloadError>()
|| error.is::<poem_openapi::error::ContentTypeError>()
|| error.is::<poem_openapi::error::ParseParamError>()
|| error.is::<poem_openapi::error::ParsePathError>(),
ErrorCode::InvalidParameter,
),
(
error.is::<poem::error::MethodNotAllowedError>(),
ErrorCode::MethodNotAllowed,
),
(
error.is::<poem_openapi::error::AuthorizationError>(),
ErrorCode::PermissionDenied,
),
];
// Find the first matching error type
if let Some((_, error_code)) = error_mapping.iter().find(|(condition, _)| *condition) {
let api_error = ApiError::new_with_error_code(error.to_string(), *error_code as u32);
let mut response =
ApiErrorResponse::Generic(error_code.status(), Json(api_error)).into_response();
response.set_status(error.status());
return response;
}
// Handle other cases
if error.has_source() {
let api_error =
ApiError::new_with_error_code(error.to_string(), ErrorCode::UnhandledPoemError as u32);
let mut response =
ApiErrorResponse::Generic(ErrorCode::UnhandledPoemError.status(), Json(api_error))
.into_response();
response.set_status(error.status());
response
} else {
error.into_response()
}
}
+111
View File
@@ -0,0 +1,111 @@
//
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use std::{fmt::Formatter, u32};
use crate::raise_error;
use bb8::RunError;
use code::ErrorCode;
use poem::http::StatusCode;
use poem_openapi::{payload::Json, ApiResponse, Object};
use snafu::{Location, Snafu};
pub mod code;
pub mod handler;
#[derive(Debug, Snafu)]
#[snafu(visibility(pub))]
pub enum BichonError {
#[snafu(display("{message}"))]
Generic {
message: String,
#[snafu(implicit)]
location: Location,
code: ErrorCode,
},
}
pub type BichonResult<T, E = BichonError> = std::result::Result<T, E>;
impl From<RunError<BichonError>> for BichonError {
fn from(e: RunError<BichonError>) -> Self {
match e {
RunError::User(e) => e,
RunError::TimedOut => raise_error!(
"Timed out while attempting to acquire a connection from the pool".into(),
ErrorCode::ConnectionPoolTimeout
),
}
}
}
#[derive(Debug, Clone, Object)]
pub struct ApiError {
pub message: String,
pub code: u32,
}
impl From<BichonError> for ApiErrorResponse {
fn from(error: BichonError) -> Self {
match error {
BichonError::Generic {
message,
location,
code,
} => {
tracing::error!(
"API error occurred: [{:#?}] {} at {:?}",
code,
message,
location
);
let api_error = ApiError {
message,
code: code as u32,
};
ApiErrorResponse::Generic(code.status(), Json(api_error))
}
}
}
}
impl ApiError {
pub fn new(message: String, code: u32) -> Self {
Self { message, code }
}
pub fn new_with_error_code<ErrorType: std::fmt::Display>(
error: ErrorType,
code: u32,
) -> ApiError {
Self::new(format!("{:#}", error), code)
}
}
impl std::fmt::Display for ApiError {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
write!(f, "Error({}): {}", self.code, self.message)
}
}
impl std::error::Error for ApiError {}
#[derive(Debug, Clone, ApiResponse)]
pub enum ApiErrorResponse {
Generic(StatusCode, Json<ApiError>),
}
+51
View File
@@ -0,0 +1,51 @@
//
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use crate::modules::error::code::ErrorCode;
use crate::modules::imap::session::SessionStream;
use crate::{modules::error::BichonResult, raise_error};
use async_imap::types::Capability;
use async_imap::{types::Capabilities, Session};
pub async fn fetch_capabilities(
session: &mut Session<Box<dyn SessionStream>>,
) -> BichonResult<Capabilities> {
session
.capabilities()
.await
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::ImapCommandFailed))
}
pub fn check_capabilities(capabilities: &Capabilities) -> BichonResult<()> {
if !capabilities.has_str("IMAP4rev1") {
return Err(raise_error!(
"Server does not support IMAP4rev1".into(),
ErrorCode::Incompatible
));
}
Ok(())
}
pub fn capability_to_string(capability: &Capability) -> String {
match capability {
Capability::Imap4rev1 => "IMAP4rev1".into(),
Capability::Auth(v) => format!("AUTH={}", v),
Capability::Atom(v) => v.into(),
}
}
+251
View File
@@ -0,0 +1,251 @@
//
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use crate::modules::account::entity::Encryption;
use crate::modules::error::code::ErrorCode;
use crate::modules::error::BichonResult;
use crate::modules::imap::session::SessionStream;
use crate::modules::imap::stats::StatsWrapper;
use crate::modules::utils::net::establish_tcp_connection_with_timeout;
use crate::modules::utils::net::establish_tls_connection;
use crate::modules::utils::tls::establish_tls_stream;
use crate::raise_error;
use async_imap::Client as ImapClient;
use async_imap::Session as ImapSession;
use std::net::SocketAddr;
use std::net::ToSocketAddrs;
use std::ops::Deref;
use std::ops::DerefMut;
use tokio::io::BufWriter;
use tracing::debug;
#[derive(Debug)]
pub(crate) struct Client {
inner: ImapClient<Box<dyn SessionStream>>,
}
impl Deref for Client {
type Target = ImapClient<Box<dyn SessionStream>>;
fn deref(&self) -> &Self::Target {
&self.inner
}
}
impl DerefMut for Client {
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.inner
}
}
fn alpn(port: u16) -> &'static [&'static str] {
if port == 993 {
&[]
} else {
&["imap"]
}
}
impl Client {
fn new(stream: Box<dyn SessionStream>) -> Self {
Self {
inner: ImapClient::new(stream),
}
}
pub(crate) async fn login(
self,
username: &str,
password: &str,
) -> BichonResult<ImapSession<Box<dyn SessionStream>>> {
let Client { inner, .. } = self;
let session = inner.login(username, password).await.map_err(|(e, _)| {
raise_error!(format!("{:#?}", e), ErrorCode::ImapAuthenticationFailed)
})?;
Ok(session)
}
pub(crate) async fn authenticate(
self,
authenticator: impl async_imap::Authenticator,
) -> BichonResult<ImapSession<Box<dyn SessionStream>>> {
let Client { inner, .. } = self;
let session = inner
.authenticate("XOAUTH2", authenticator)
.await
.map_err(|(e, _)| {
raise_error!(format!("{:#?}", e), ErrorCode::ImapAuthenticationFailed)
})?;
Ok(session)
}
pub async fn connection(
domain: &str,
encryption: &Encryption,
port: u16,
use_proxy: Option<u64>,
) -> BichonResult<Self> {
let resolved_addr = Self::resolve_to_socket_addr(domain, port)?;
debug!("Attempting IMAP connection to {domain} ({resolved_addr}).");
match encryption {
Encryption::Ssl => {
Self::establish_secure_connection(resolved_addr, domain, use_proxy).await
}
Encryption::StartTls => {
Self::establish_starttls_connection(resolved_addr, domain, use_proxy).await
}
Encryption::None => Self::establish_insecure_connection(resolved_addr, use_proxy).await,
}
}
async fn establish_secure_connection(
address: SocketAddr,
server_hostname: &str,
use_proxy: Option<u64>,
) -> BichonResult<Self> {
// Establish the TLS connection with the specified parameters
let tls_stream =
establish_tls_connection(address, server_hostname, alpn(address.port()), use_proxy)
.await?;
let stats_stream = StatsWrapper::new(tls_stream);
// Wrap the TLS stream in a buffered writer for efficient IO
let buffered_stream = BufWriter::new(stats_stream);
// Create a SessionStream trait object for further communication
let session_stream = Box::new(buffered_stream);
// Initialize the client with the session stream
let mut client = Client::new(session_stream);
// Read and validate the greeting response
let _greeting = client
.read_response()
.await
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::ImapCommandFailed))?
.ok_or_else(|| {
raise_error!(
"failed to read greeting".into(),
ErrorCode::ImapCommandFailed
)
})?;
// Return the established client
Ok(client)
}
async fn establish_insecure_connection(
address: SocketAddr,
use_proxy: Option<u64>,
) -> BichonResult<Self> {
// Establish the TCP connection without encryption
let tcp_stream = establish_tcp_connection_with_timeout(address, use_proxy).await?;
let stats_stream = StatsWrapper::new(tcp_stream);
// Wrap the TCP stream in a buffered writer for efficient IO
let buffered_stream = BufWriter::new(stats_stream);
// Create a SessionStream trait object for further communication
let session_stream: Box<dyn SessionStream> = Box::new(buffered_stream);
// Initialize the client with the session stream
let mut client = Client::new(session_stream);
// Read and validate the greeting response
let _greeting = client
.read_response()
.await
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::ImapCommandFailed))?
.ok_or_else(|| {
raise_error!(
"failed to read greeting".into(),
ErrorCode::ImapCommandFailed
)
})?;
// Return the established client
Ok(client)
}
async fn establish_starttls_connection(
address: SocketAddr,
server_hostname: &str,
use_proxy: Option<u64>,
) -> BichonResult<Self> {
// Establish the initial TCP connection
let tcp_stream = establish_tcp_connection_with_timeout(address, use_proxy).await?;
let stats_stream = StatsWrapper::new(tcp_stream);
// Wrap the TCP stream in a buffered writer for efficient IO
let buffered_tcp_stream = BufWriter::new(stats_stream);
// Create a client for communication
let mut client = async_imap::Client::new(buffered_tcp_stream);
// Read and validate the greeting response
let _greeting = client
.read_response()
.await
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::ImapCommandFailed))?
.ok_or_else(|| {
raise_error!(
"failed to read greeting".into(),
ErrorCode::ImapCommandFailed
)
})?;
// Run the STARTTLS command to upgrade the connection to TLS
client
.run_command_and_check_ok("STARTTLS", None)
.await
.map_err(|_| {
raise_error!(
"STARTTLS command failed".into(),
ErrorCode::ImapCommandFailed
)
})?;
// Extract the TCP stream after running STARTTLS
let buffered_tcp_stream = client.into_inner();
let tcp_stream = buffered_tcp_stream.into_inner();
// Wrap the TCP stream in TLS encryption
let tls_stream = establish_tls_stream(server_hostname, &[], tcp_stream).await?;
// Wrap the TLS stream in a buffered writer
let buffered_stream = BufWriter::new(tls_stream);
// Create a SessionStream trait object for further communication
let session_stream: Box<dyn SessionStream> = Box::new(buffered_stream);
// Initialize the client with the session stream
let client = Client::new(session_stream);
// Return the established client
Ok(client)
}
fn resolve_to_socket_addr(domain: &str, port: u16) -> BichonResult<SocketAddr> {
if domain.is_empty() || domain.contains(|c: char| !c.is_ascii() && c != '.') {
return Err(raise_error!(
"Invalid domain format".into(),
ErrorCode::InvalidParameter
));
}
// Combine domain and port into a single address string
let address = format!("{}:{}", domain, port);
// Resolve the address into a SocketAddr
let socket_addrs = address
.to_socket_addrs()
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::NetworkError))?;
// Return the first valid SocketAddr
socket_addrs.into_iter().next().ok_or_else(|| {
raise_error!("Unable to resolve address".into(), ErrorCode::NetworkError)
})
}
}
+241
View File
@@ -0,0 +1,241 @@
//
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use crate::modules::account::state::AccountRunningState;
use crate::modules::cache::imap::mailbox::MailBox;
use crate::modules::cache::imap::sync::flow::{generate_uid_sequence_hashset, BATCH_SIZE};
use crate::modules::envelope::extractor::extract_envelope;
use crate::modules::error::code::ErrorCode;
use crate::modules::indexer::manager::{EML_INDEX_MANAGER, ENVELOPE_INDEX_MANAGER};
use crate::modules::indexer::schema::SchemaTools;
use crate::modules::{error::BichonResult, imap::manager::ImapConnectionManager};
use crate::raise_error;
use async_imap::types::{Mailbox, Name};
use bb8::Pool;
use futures::TryStreamExt;
use std::collections::HashSet;
use tantivy::doc;
use tracing::info;
const BODY_FETCH_COMMAND: &str = "(UID INTERNALDATE RFC822.SIZE BODY.PEEK[])";
pub struct ImapExecutor {
pool: Pool<ImapConnectionManager>,
}
impl ImapExecutor {
pub fn new(pool: Pool<ImapConnectionManager>) -> Self {
Self { pool }
}
pub async fn list_all_mailboxes(&self) -> BichonResult<Vec<Name>> {
let mut session = self.pool.get().await?;
let list = session
.list(Some(""), Some("*"))
.await
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::ImapCommandFailed))?;
let result = list
.try_collect::<Vec<Name>>()
.await
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::ImapCommandFailed))?;
Ok(result)
}
pub async fn examine_mailbox(&self, mailbox_name: &str) -> BichonResult<Mailbox> {
let mut session = self.pool.get().await?;
session
.examine(mailbox_name)
.await
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::ImapCommandFailed))
}
pub async fn uid_search(&self, mailbox_name: &str, query: &str) -> BichonResult<HashSet<u32>> {
let mut session = self.pool.get().await?;
session
.examine(mailbox_name)
.await
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::ImapCommandFailed))?;
let result = session
.uid_search(query)
.await
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::ImapCommandFailed))?;
Ok(result)
}
pub async fn fetch_new_mail(
&self,
account_id: u64,
mailbox: &MailBox,
start_uid: u64,
) -> BichonResult<()> {
assert!(start_uid > 0, "start_uid must be greater than 0");
let uid_list = self
.uid_search(
&mailbox.encoded_name(),
format!("UID {start_uid}:*").as_str(),
)
.await?;
let len = uid_list.len();
if len == 0 {
return Ok(());
}
info!(
"[account {}][mailbox {}] {} envelopes need to be fetched",
account_id, mailbox.name, len
);
let mut uid_vec: Vec<u32> = uid_list.into_iter().collect();
uid_vec.sort();
let uid_batches = generate_uid_sequence_hashset(uid_vec, BATCH_SIZE as usize, false);
let too_many = len as u32 > 10 * BATCH_SIZE;
if too_many {
AccountRunningState::set_initial_current_syncing_folder(
account_id,
mailbox.name.clone(),
uid_batches.len() as u32,
)
.await?;
}
for (index, batch) in uid_batches.into_iter().enumerate() {
if too_many {
AccountRunningState::set_current_sync_batch_number(
account_id,
mailbox.name.clone(),
(index + 1) as u32,
)
.await?;
}
self.uid_batch_retrieve_emails(account_id, mailbox.id, &batch, &mailbox.encoded_name())
.await?;
}
Ok(())
}
pub async fn batch_retrieve_emails(
&self,
account_id: u64,
mailbox_id: u64,
page: u64,
page_size: u64,
encoded_mailbox_name: &str,
desc: bool,
) -> BichonResult<usize> {
assert!(page > 0, "Page number must be greater than 0");
assert!(page_size > 0, "Page size must be greater than 0");
let mut session = self.pool.get().await?;
let total = session
.examine(encoded_mailbox_name)
.await
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::ImapCommandFailed))?
.exists as u64;
if total == 0 {
return Ok(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)
};
let sequence_set = format!("{}:{}", start, end);
info!(
"Fetching mailbox '{}' messages: sequence {} (page {}, page_size {}, desc={})",
encoded_mailbox_name, sequence_set, page, page_size, desc
);
let mut stream = session
.fetch(sequence_set.as_str(), BODY_FETCH_COMMAND)
.await
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::ImapCommandFailed))?;
let mut count = 0;
let fields = SchemaTools::eml_fields();
while let Some(fetch) = stream
.try_next()
.await
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::ImapCommandFailed))?
{
let envelope = extract_envelope(&fetch, account_id, mailbox_id)?;
ENVELOPE_INDEX_MANAGER
.add_document(envelope.id, envelope.to_document(mailbox_id)?)
.await;
let body = fetch.body().ok_or_else(|| {
raise_error!("missing a body".into(), ErrorCode::ImapUnexpectedResult)
})?;
EML_INDEX_MANAGER.add_document( envelope.id, doc!(fields.f_id => envelope.id, fields.f_account_id => account_id, fields.f_mailbox_id => mailbox_id, fields.f_eml => body)).await;
count += 1;
}
Ok(count)
}
pub async fn uid_batch_retrieve_emails(
&self,
account_id: u64,
mailbox_id: u64,
uid_set: &str,
encoded_mailbox_name: &str,
) -> BichonResult<()> {
let mut session = self.pool.get().await?;
session
.examine(encoded_mailbox_name)
.await
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::ImapCommandFailed))?;
let mut stream = session
.uid_fetch(uid_set, BODY_FETCH_COMMAND)
.await
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::ImapCommandFailed))?;
let fields = SchemaTools::eml_fields();
while let Some(fetch) = stream
.try_next()
.await
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::ImapCommandFailed))?
{
let envelope = extract_envelope(&fetch, account_id, mailbox_id)?;
ENVELOPE_INDEX_MANAGER
.add_document(envelope.id, envelope.to_document(mailbox_id)?)
.await;
let body = fetch.body().ok_or_else(|| {
raise_error!("missing a body".into(), ErrorCode::ImapUnexpectedResult)
})?;
EML_INDEX_MANAGER.add_document( envelope.id, doc!(fields.f_id => envelope.id, fields.f_account_id => account_id, fields.f_mailbox_id => mailbox_id, fields.f_eml => body)).await;
}
Ok(())
}
}
+155
View File
@@ -0,0 +1,155 @@
//
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use crate::modules::account::dispatcher::STATUS_DISPATCHER;
use crate::modules::account::entity::AuthType;
use crate::modules::account::migration::{AccountModel, AccountType};
use crate::modules::error::code::ErrorCode;
use crate::modules::error::BichonResult;
use crate::modules::imap::capabilities::{
capability_to_string, check_capabilities, fetch_capabilities,
};
use crate::modules::imap::client::Client;
use crate::modules::imap::oauth2::OAuth2;
use crate::modules::imap::session::SessionStream;
use crate::modules::oauth2::token::OAuth2AccessToken;
use crate::{decrypt, raise_error};
use async_imap::Session;
use tracing::error;
#[derive(Debug)]
pub struct ImapConnectionManager {
pub account_id: u64,
}
impl ImapConnectionManager {
pub fn new(account_id: u64) -> Self {
Self { account_id }
}
pub async fn fetch_account(&self) -> BichonResult<AccountModel> {
// Fetch the account entity in non-test environment
AccountModel::get(self.account_id).await
}
async fn create_client(&self, account: &AccountModel) -> BichonResult<Client> {
assert_eq!(account.account_type, AccountType::IMAP);
let imap = account.imap.as_ref().unwrap();
Client::connection(&imap.host, &imap.encryption, imap.port, imap.use_proxy).await
}
async fn authenticate(
&self,
client: Client,
account: &AccountModel,
) -> BichonResult<Session<Box<dyn SessionStream>>> {
assert_eq!(account.account_type, AccountType::IMAP);
let imap = account.imap.as_ref().unwrap();
match &imap.auth.auth_type {
AuthType::Password => {
let password = &imap.auth.password.clone().ok_or_else(|| {
raise_error!(
"Imap auth type is Passwd, but password not set".into(),
ErrorCode::MissingConfiguration
)
})?;
let password = decrypt!(&password)?;
client.login(&account.email, &password).await
}
AuthType::OAuth2 => {
let record = OAuth2AccessToken::get(self.account_id).await?;
let access_token = record.and_then(|r| r.access_token).ok_or_else(|| {
raise_error!(
"Imap auth type is OAuth2, but OAuth2 authorization is not yet complete."
.into(),
ErrorCode::MissingConfiguration
)
})?;
client
.authenticate(OAuth2::new(account.email.clone(), access_token))
.await
}
}
}
pub async fn build(&self) -> BichonResult<Session<Box<dyn SessionStream>>> {
let account = self.fetch_account().await?;
let client = match self.create_client(&account).await {
Ok(client) => client,
Err(error) => {
error!(
"Failed to create IMAP {}'s client: {:#?}",
&account.email, error
);
STATUS_DISPATCHER
.append_error(
self.account_id,
format!("imap client connect error: {:#?}", error),
)
.await;
return Err(error);
}
};
let mut session = match self.authenticate(client, &account).await {
Ok(session) => session,
Err(error) => {
error!("Failed to authenticate IMAP session: {:#?}", error);
STATUS_DISPATCHER
.append_error(
self.account_id,
format!("imap client authenticate error: {:#?}", error),
)
.await;
return Err(error);
}
};
match fetch_capabilities(&mut session).await {
Ok(capabilities) => {
let to_save: Vec<String> = capabilities.iter().map(capability_to_string).collect();
AccountModel::update_capabilities(self.account_id, to_save).await?;
if let Err(error) = check_capabilities(&capabilities) {
error!("Failed to check IMAP capabilities: {:#?}", error);
STATUS_DISPATCHER
.append_error(
self.account_id,
format!("imap client check capabilities error: {:#?}", error),
)
.await;
return Err(error);
}
}
Err(error) => {
error!("Failed to fetch IMAP capabilities: {:#?}", error);
STATUS_DISPATCHER
.append_error(
self.account_id,
format!("imap client fetch capabilities error: {:#?}", error),
)
.await;
return Err(error);
}
}
Ok(session)
}
}
+29
View File
@@ -0,0 +1,29 @@
//
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
pub mod capabilities;
pub mod client;
pub mod executor;
pub mod manager;
pub mod oauth2;
pub mod pool;
pub mod session;
pub mod stats;
#[cfg(test)]
mod tests;
+41
View File
@@ -0,0 +1,41 @@
//
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
#[derive(Debug)]
pub struct OAuth2 {
user: String,
access_token: String,
}
impl OAuth2 {
pub fn new(user: String, access_token: String) -> Self {
Self { user, access_token }
}
}
impl async_imap::Authenticator for OAuth2 {
type Response = String;
fn process(&mut self, _data: &[u8]) -> Self::Response {
format!(
"user={}\x01auth=Bearer {}\x01\x01",
self.user, self.access_token
)
}
}
+60
View File
@@ -0,0 +1,60 @@
//
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use crate::modules::error::code::ErrorCode;
use crate::modules::error::{BichonError, BichonResult};
use crate::modules::imap::{manager::ImapConnectionManager, session::SessionStream};
use crate::raise_error;
use async_imap::Session;
use bb8::Pool;
use std::time::Duration;
impl bb8::ManageConnection for ImapConnectionManager {
type Connection = Session<Box<dyn SessionStream>>;
type Error = BichonError;
async fn connect(&self) -> BichonResult<Self::Connection> {
self.build().await
}
// call this function before using the connection
async fn is_valid(&self, conn: &mut Self::Connection) -> BichonResult<()> {
conn.noop()
.await
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::ImapCommandFailed))
}
fn has_broken(&self, _: &mut Self::Connection) -> bool {
false
}
}
pub async fn build_imap_pool(account_id: u64) -> BichonResult<Pool<ImapConnectionManager>> {
let manager = ImapConnectionManager::new(account_id);
let pool = Pool::builder()
.connection_timeout(Duration::from_secs(30))
.idle_timeout(Duration::from_secs(120))
.retry_connection(true)
.max_size(10)
.test_on_check_out(true)
.build(manager)
.await?;
Ok(pool)
}
+52
View File
@@ -0,0 +1,52 @@
//
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use std::pin::Pin;
use tokio::io::{AsyncRead, AsyncWrite, BufWriter};
use tokio_io_timeout::TimeoutStream;
pub trait SessionStream: AsyncRead + AsyncWrite + Unpin + Send + Sync + std::fmt::Debug {
// Change the read timeout on the session stream.
// fn set_read_timeout(&mut self, timeout: Option<Duration>);
}
impl SessionStream for Box<dyn SessionStream> {
// fn set_read_timeout(&mut self, timeout: Option<Duration>) {
// self.as_mut().set_read_timeout(timeout);
// }
}
impl<T: SessionStream> SessionStream for tokio_rustls::client::TlsStream<T> {
// fn set_read_timeout(&mut self, timeout: Option<Duration>) {
// self.get_mut().0.set_read_timeout(timeout);
// }
}
impl<T: SessionStream> SessionStream for BufWriter<T> {
// fn set_read_timeout(&mut self, timeout: Option<Duration>) {
// self.get_mut().set_read_timeout(timeout);
// }
}
impl<T: AsyncRead + AsyncWrite + Send + Sync + std::fmt::Debug> SessionStream
for Pin<Box<TimeoutStream<T>>>
{
// fn set_read_timeout(&mut self, timeout: Option<Duration>) {
// self.as_mut().set_read_timeout_pinned(timeout);
// }
}
+86
View File
@@ -0,0 +1,86 @@
//
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use std::pin::Pin;
use std::task::{Context, Poll};
// use std::time::Duration;
use tokio::io::{AsyncRead, AsyncWrite, ReadBuf};
use crate::modules::imap::session::SessionStream;
pub struct StatsWrapper<T> {
inner: T,
}
impl<T> StatsWrapper<T> {
pub fn new(inner: T) -> Self {
Self { inner }
}
}
impl<T: AsyncRead + Unpin> AsyncRead for StatsWrapper<T> {
fn poll_read(
mut self: Pin<&mut Self>,
cx: &mut Context<'_>,
buf: &mut ReadBuf<'_>,
) -> Poll<std::io::Result<()>> {
// let before = buf.filled().len();
let result = Pin::new(&mut self.inner).poll_read(cx, buf);
// if let Poll::Ready(Ok(())) = &result {
// // let bytes_read = buf.filled().len() - before;
// }
result
}
}
impl<T: AsyncWrite + Unpin> AsyncWrite for StatsWrapper<T> {
fn poll_write(
mut self: Pin<&mut Self>,
cx: &mut Context<'_>,
buf: &[u8],
) -> Poll<std::io::Result<usize>> {
let result = Pin::new(&mut self.inner).poll_write(cx, buf);
// if let Poll::Ready(Ok(bytes_written)) = &result {
// }
result
}
fn poll_flush(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<std::io::Result<()>> {
Pin::new(&mut self.inner).poll_flush(cx)
}
fn poll_shutdown(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<std::io::Result<()>> {
Pin::new(&mut self.inner).poll_shutdown(cx)
}
}
impl<T: SessionStream> SessionStream for StatsWrapper<T> {
// fn set_read_timeout(&mut self, timeout: Option<Duration>) {
// self.inner.set_read_timeout(timeout);
// }
}
impl<T: SessionStream> std::fmt::Debug for StatsWrapper<T> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("StatsWrapper")
.field("inner", &self.inner)
.finish()
}
}
+48
View File
@@ -0,0 +1,48 @@
//
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use mail_parser::MessageParser;
use crate::{base64_encode_url_safe, modules::{account::entity::Encryption, imap::client::Client}};
#[tokio::test]
async fn testxx() {
rustls::crypto::CryptoProvider::install_default(rustls::crypto::ring::default_provider())
.unwrap();
let client = Client::connection("imap.zoho.com".into(), &Encryption::Ssl, 993, None)
.await
.unwrap();
let mut session = client.login("pollybase@zohomail.com", "xxx").await.unwrap();
session.select("INBOX").await.unwrap();
let result = session.uid_search("LARGER 1024").await.unwrap();
println!("{:#?}", result);
}
#[tokio::test]
async fn test1() {
let path = r"C:\Users\polly\Downloads\test.eml";
let eml_data = std::fs::read(path).unwrap();
let input = base64_encode_url_safe!(eml_data);
let message = MessageParser::default().parse(&input).unwrap();
let parts = message.parts;
for part in parts {
println!("{}", part.is_message());
println!("{}", part.is_multipart());
}
}
+194
View File
@@ -0,0 +1,194 @@
//
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use crate::modules::error::code::ErrorCode;
use crate::modules::utils::create_hash;
use crate::modules::{error::BichonResult, indexer::schema::SchemaTools};
use crate::raise_error;
use poem_openapi::Object;
use serde::{Deserialize, Serialize};
use tantivy::schema::Facet;
use tantivy::{doc, schema::Value, TantivyDocument};
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize, Object)]
pub struct Envelope {
pub id: u64,
pub message_id: String,
pub account_id: u64,
pub mailbox_id: u64,
pub uid: u32,
pub subject: String,
pub text: String,
pub from: String,
pub to: Vec<String>,
pub cc: Vec<String>,
pub bcc: Vec<String>,
pub date: i64,
pub internal_date: i64,
pub size: u32,
pub thread_id: u64,
pub attachments: Vec<String>,
pub tags: Option<Vec<String>>,
}
fn extract_u64_field(
document: &TantivyDocument,
field: tantivy::schema::Field,
) -> BichonResult<u64> {
let value = document.get_first(field).ok_or_else(|| {
raise_error!(
format!("miss '{}' field in tantivy document", stringify!(field)),
ErrorCode::InternalError
)
})?;
value.as_u64().ok_or_else(|| {
raise_error!(
format!("'{}' field is not a u64", stringify!(field)),
ErrorCode::InternalError
)
})
}
fn extract_i64_field(
document: &TantivyDocument,
field: tantivy::schema::Field,
) -> BichonResult<i64> {
let value = document.get_first(field).ok_or_else(|| {
raise_error!(
format!("miss '{}' field in tantivy document", stringify!(field)),
ErrorCode::InternalError
)
})?;
value.as_i64().ok_or_else(|| {
raise_error!(
format!("'{}' field is not a i64", stringify!(field)),
ErrorCode::InternalError
)
})
}
fn extract_string_field(
document: &TantivyDocument,
field: tantivy::schema::Field,
) -> BichonResult<String> {
let value = document.get_first(field).ok_or_else(|| {
raise_error!(
format!("'{}' field not found", stringify!(field)),
ErrorCode::InternalError
)
})?;
value.as_str().map(|s| s.to_string()).ok_or_else(|| {
raise_error!(
format!("'{}' field is not a string", stringify!(field)),
ErrorCode::InternalError
)
})
}
fn extract_vec_string_field(
document: &TantivyDocument,
field: tantivy::schema::Field,
) -> BichonResult<Vec<String>> {
let value = document
.get_all(field)
.filter_map(|v| v.as_str().map(|s| s.to_string()))
.collect();
Ok(value)
}
impl Envelope {
pub fn to_document(&self, mailbox_id: u64) -> BichonResult<TantivyDocument> {
let fields = SchemaTools::envelope_fields();
let mut doc = doc!();
doc.add_u64(fields.f_id, self.id);
doc.add_text(fields.f_message_id, &self.message_id);
doc.add_u64(fields.f_account_id, self.account_id);
doc.add_u64(fields.f_mailbox_id, mailbox_id);
doc.add_u64(fields.f_uid, self.uid as u64);
doc.add_text(fields.f_subject, &self.subject);
doc.add_text(fields.f_text, &self.text);
doc.add_text(fields.f_from, &self.from);
for to in &self.to {
doc.add_text(fields.f_to, to);
}
for cc in &self.cc {
doc.add_text(fields.f_cc, cc);
}
for bcc in &self.bcc {
doc.add_text(fields.f_bcc, bcc);
}
doc.add_i64(fields.f_date, self.date);
doc.add_i64(fields.f_internal_date, self.internal_date);
doc.add_u64(fields.f_size, self.size as u64);
doc.add_u64(fields.f_thread_id, self.thread_id);
for att in &self.attachments {
doc.add_text(fields.f_attachments, att);
}
doc.add_bool(fields.f_has_attachment, self.attachments.len() > 0);
Ok(doc)
}
pub async fn from_tantivy_doc(doc: &TantivyDocument) -> BichonResult<Self> {
let fields = SchemaTools::envelope_fields();
let account_id = extract_u64_field(doc, fields.f_account_id)?;
let message_id = extract_string_field(doc, fields.f_message_id)?;
let mailbox_id = extract_u64_field(doc, fields.f_mailbox_id)?;
let id = create_hash(account_id, &message_id);
let full_text = extract_string_field(doc, fields.f_text)?;
// Take up to the first 120 characters as a preview;
let preview = if full_text.chars().count() > 120 {
full_text.chars().take(120).collect::<String>() + "..."
} else {
full_text
};
let tags: Vec<String> = doc
.get_all(fields.f_tags)
.filter_map(|value| value.as_facet())
.map(|facet_encoded_str| {
Facet::from_encoded(facet_encoded_str.as_bytes().to_vec())
.ok()
.map(|facet| facet.to_string())
})
.flatten()
.collect();
let envelope = Envelope {
id,
account_id,
mailbox_id,
message_id: extract_string_field(doc, fields.f_message_id)?,
uid: extract_u64_field(doc, fields.f_uid)? as u32,
subject: extract_string_field(doc, fields.f_subject)?,
text: preview,
from: extract_string_field(doc, fields.f_from)?,
to: extract_vec_string_field(doc, fields.f_to)?,
cc: extract_vec_string_field(doc, fields.f_cc)?,
bcc: extract_vec_string_field(doc, fields.f_bcc)?,
date: extract_i64_field(doc, fields.f_date)?,
internal_date: extract_i64_field(doc, fields.f_internal_date)?,
size: extract_u64_field(doc, fields.f_size)? as u32,
thread_id: extract_u64_field(doc, fields.f_thread_id)?,
attachments: extract_vec_string_field(doc, fields.f_attachments)?,
tags: Some(tags),
};
Ok(envelope)
}
}
+69
View File
@@ -0,0 +1,69 @@
//
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use tantivy::schema::Field;
pub const F_MESSAGE_ID: &str = "message_id";
pub const F_ACCOUNT_ID: &str = "account_id";
pub const F_MAILBOX_ID: &str = "mailbox_id";
pub const F_UID: &str = "uid";
pub const F_SUBJECT: &str = "subject";
pub const F_TEXT: &str = "text";
pub const F_FROM: &str = "from";
pub const F_TO: &str = "to";
pub const F_CC: &str = "cc";
pub const F_BCC: &str = "bcc";
pub const F_DATE: &str = "date";
pub const F_INTERNAL_DATE: &str = "internal_date";
pub const F_SIZE: &str = "size";
pub const F_THREAD_ID: &str = "thread_id";
pub const F_ATTACHMENTS: &str = "attachments";
pub const F_HAS_ATTACHMENT: &str = "has_attachment";
pub const F_TAGS: &str = "tags";
pub const F_ID: &str = "id";
pub struct EnvelopeFields {
pub f_id: Field,
pub f_message_id: Field,
pub f_account_id: Field,
pub f_mailbox_id: Field,
pub f_uid: Field,
pub f_subject: Field,
pub f_text: Field,
pub f_from: Field,
pub f_to: Field,
pub f_cc: Field,
pub f_bcc: Field,
pub f_date: Field,
pub f_internal_date: Field,
pub f_size: Field,
pub f_thread_id: Field,
pub f_attachments: Field,
pub f_has_attachment: Field,
pub f_tags: Field,
}
pub const F_EML: &str = "eml";
pub struct EmlFields {
pub f_id: Field,
pub f_account_id: Field,
pub f_mailbox_id: Field,
pub f_eml: Field,
}
File diff suppressed because it is too large Load Diff
+25
View File
@@ -0,0 +1,25 @@
//
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
pub mod envelope;
pub mod fields;
pub mod manager;
pub mod schema;
#[cfg(test)]
mod tests;
+128
View File
@@ -0,0 +1,128 @@
//
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use std::sync::{Arc, LazyLock};
use crate::modules::indexer::fields::{EnvelopeFields, *};
use tantivy::schema::{FacetOptions, Field, INDEXED};
use tantivy::schema::{Schema, FAST, STORED, STRING, TEXT};
static ENVELOPE_FIELDS: LazyLock<Arc<EnvelopeFields>> = LazyLock::new(|| {
let (_, fields) = SchemaTools::create_envelope_schema();
Arc::new(fields)
});
static EML_FIELDS: LazyLock<Arc<EmlFields>> = LazyLock::new(|| {
let (_, fields) = SchemaTools::create_eml_schema();
Arc::new(fields)
});
pub struct SchemaTools;
impl SchemaTools {
pub fn envelope_schema() -> Schema {
let (schema, _) = Self::create_envelope_schema();
schema
}
pub fn eml_schema() -> Schema {
let (schema, _) = Self::create_eml_schema();
schema
}
pub fn envelope_fields() -> &'static EnvelopeFields {
&ENVELOPE_FIELDS
}
pub fn eml_fields() -> &'static EmlFields {
&EML_FIELDS
}
pub fn envelope_default_fields() -> Vec<Field> {
let fields = Self::envelope_fields();
vec![fields.f_subject, fields.f_text, fields.f_attachments]
}
pub fn create_envelope_schema() -> (Schema, EnvelopeFields) {
let mut builder = Schema::builder();
let f_id = builder.add_u64_field(F_ID, INDEXED | STORED | FAST);
// Account/ Mailbox IDs: numeric, for filtering/aggregation
let f_account_id = builder.add_u64_field(F_ACCOUNT_ID, INDEXED | STORED | FAST);
let f_mailbox_id = builder.add_u64_field(F_MAILBOX_ID, INDEXED | STORED | FAST);
// UID: numeric, locate message
let f_uid = builder.add_u64_field(F_UID, INDEXED | STORED | FAST);
// Subject/body: tokenized for full-text search
let f_subject = builder.add_text_field(F_SUBJECT, TEXT | STORED);
let f_text = builder.add_text_field(F_TEXT, TEXT | STORED);
// Email addresses: exact match search
let f_from = builder.add_text_field(F_FROM, STRING | STORED | FAST);
let f_to = builder.add_text_field(F_TO, STRING | STORED);
let f_cc = builder.add_text_field(F_CC, STRING | STORED);
let f_bcc = builder.add_text_field(F_BCC, STRING | STORED);
// Date fields: numeric, range filtering
let f_date = builder.add_i64_field(F_DATE, STORED | FAST);
let f_internal_date = builder.add_i64_field(F_INTERNAL_DATE, STORED | FAST);
// Size: numeric, range filtering
let f_size = builder.add_u64_field(F_SIZE, STORED | FAST);
// Thread ID: numeric, filter by thread
let f_thread_id = builder.add_u64_field(F_THREAD_ID, INDEXED | STORED | FAST);
// Message-ID: unique identifier, no tokenization
let f_message_id = builder.add_text_field(F_MESSAGE_ID, STRING | STORED);
// Attachments: exact match search
let f_attachments = builder.add_text_field(F_ATTACHMENTS, TEXT | STORED);
let f_has_attachment = builder.add_bool_field(F_HAS_ATTACHMENT, INDEXED | STORED | FAST);
let f_tags = builder.add_facet_field(F_TAGS, FacetOptions::default().set_stored());
let fields = EnvelopeFields {
f_id,
f_account_id,
f_mailbox_id,
f_uid,
f_subject,
f_text,
f_from,
f_to,
f_cc,
f_bcc,
f_date,
f_internal_date,
f_size,
f_thread_id,
f_message_id,
f_attachments,
f_has_attachment,
f_tags,
};
(builder.build(), fields)
}
pub fn create_eml_schema() -> (Schema, EmlFields) {
let mut builder = Schema::builder();
let f_id = builder.add_u64_field(F_ID, INDEXED | FAST);
let f_account_id = builder.add_u64_field(F_ACCOUNT_ID, INDEXED | STORED | FAST);
let f_mailbox_id = builder.add_u64_field(F_MAILBOX_ID, INDEXED | STORED | FAST);
let f_eml = builder.add_bytes_field(F_EML, STORED);
let fields = EmlFields {
f_id,
f_account_id,
f_mailbox_id,
f_eml,
};
(builder.build(), fields)
}
}
+229
View File
@@ -0,0 +1,229 @@
//
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use std::{path::PathBuf, time::Duration};
use chrono::Utc;
use serde_json::json;
use tantivy::{
aggregation::{
agg_req::Aggregations,
agg_result::{AggregationResult, BucketEntries, BucketResult, MetricResult},
AggregationCollector, Key,
},
collector::TopDocs,
doc,
indexer::UserOperation,
query::{AllQuery, QueryParser, TermQuery},
schema::{IndexRecordOption, Schema, Value},
Index, IndexWriter, TantivyDocument, Term,
};
use crate::{
modules::{
dashboard::TimeBucket,
indexer::{
fields::{F_FROM, F_HAS_ATTACHMENT, F_INTERNAL_DATE, F_SIZE},
schema::SchemaTools,
},
},
utc_now,
};
#[tokio::test]
async fn test1() {
let index = Index::open_in_dir(PathBuf::from("E:/bichon-data/envelope")).unwrap();
let reader = index.reader().unwrap();
let mut query_parser = QueryParser::for_index(&index, SchemaTools::envelope_default_fields());
query_parser.set_conjunction_by_default();
let searcher = reader.searcher();
let now_ms = utc_now!();
let week_ago_ms = (Utc::now() - Duration::from_secs(60 * 60 * 24 * 7)).timestamp_millis();
let aggregations: Aggregations = serde_json::from_value(json!({
"total_size": {
"sum": { "field": F_SIZE }
},
"recent_7d_histogram": {
"histogram": {
"field": F_INTERNAL_DATE,
"interval": 86400000,
"hard_bounds": {
"min": week_ago_ms,
"max": now_ms
}
}
},
"top_from_values": {
"terms": {
"field": F_FROM,
"size": 10
}
},
"attachment_stats": {
"terms": {
"field": F_HAS_ATTACHMENT
}
}
}))
.unwrap();
let query = AllQuery;
let agg_collector = AggregationCollector::from_aggs(aggregations, Default::default());
let agg_results = searcher.search(&query, &agg_collector).unwrap();
let total_size = agg_results.0.get("total_size").unwrap();
if let AggregationResult::MetricResult(MetricResult::Sum(count)) = total_size {
let total_size = count.value.map(|v| v as u64).unwrap();
println!("{:#?}", total_size);
}
let recent_7d_histogram = agg_results.0.get("recent_7d_histogram").unwrap();
let mut recent_activity = Vec::with_capacity(15);
if let AggregationResult::BucketResult(BucketResult::Histogram { buckets, .. }) =
recent_7d_histogram
{
if let BucketEntries::Vec(bucket_list) = buckets {
for entry in bucket_list {
if let Key::F64(ms) = entry.key {
recent_activity.push(TimeBucket {
timestamp_ms: ms as i64,
count: entry.doc_count,
});
}
}
}
}
println!("recent_activity: {:#?}", recent_activity);
let top_from_values = agg_results.0.get("top_from_values").unwrap();
if let AggregationResult::BucketResult(BucketResult::Terms { buckets, .. }) = top_from_values {
println!("{:#?}", buckets);
}
let attachment_stats = agg_results.0.get("attachment_stats").unwrap();
if let AggregationResult::BucketResult(BucketResult::Terms { buckets, .. }) = attachment_stats {
println!("{:#?}", buckets);
}
//agg_results.
}
#[tokio::test]
async fn test2() {
use tantivy::schema::{FAST, INDEXED, STORED, STRING};
let mut builder = Schema::builder();
let a = builder.add_u64_field("a", INDEXED | FAST);
let b = builder.add_text_field("b", STRING | STORED | FAST);
let schema = builder.build();
let index = Index::create_in_ram(schema);
let mut index_writer: IndexWriter = index.writer(50_000_000).unwrap();
let delete_term1 = Term::from_field_u64(a, 1u64);
let delete_term2 = Term::from_field_u64(a, 2u64);
let delete_term3 = Term::from_field_u64(a, 3u64);
let operations = vec![
//UserOperation::Delete(delete_term1),
UserOperation::Add(doc!(
a => 1u64,
b => "test1"
)),
//UserOperation::Delete(delete_term2),
UserOperation::Add(doc!(
a => 2u64,
b => "test1"
)),
//UserOperation::Delete(delete_term3),
UserOperation::Add(doc!(
a => 3u64,
b => "test1"
)),
];
index_writer.run(operations).unwrap();
index_writer.commit().unwrap();
let reader = index.reader().unwrap();
let searcher = reader.searcher();
let tq = TermQuery::new(Term::from_field_u64(a, 3), IndexRecordOption::Basic);
let docs = searcher.search(&tq, &TopDocs::with_limit(1)).unwrap();
if let Some((_, doc_address)) = docs.first() {
let old_doc: TantivyDocument = searcher.doc_async(*doc_address).await.unwrap();
let mut new_doc = TantivyDocument::new();
for (field, value) in old_doc.field_values() {
if field == a {
new_doc.add_field_value(a, value);
}
}
new_doc.add_text(b, "test2");
let delete_term = Term::from_field_u64(a, 3);
index_writer.delete_term(delete_term);
index_writer.commit().unwrap();
index_writer.add_document(new_doc).unwrap();
index_writer.commit().unwrap();
}
reader.reload().unwrap();
let searcher = reader.searcher();
let docs = searcher.search(&tq, &TopDocs::with_limit(1)).unwrap();
if let Some((_, doc_address)) = docs.first() {
let doc: TantivyDocument = searcher.doc_async(*doc_address).await.unwrap();
for (field, value) in doc.field_values() {
if field == b {
let value = value.as_str();
println!("{:#?}", value);
}
}
} else {
println!("not found")
}
let delete_term = Term::from_field_u64(a, 3);
index_writer.delete_term(delete_term);
index_writer.commit().unwrap();
reader.reload().unwrap();
let searcher = reader.searcher();
let docs = searcher.search(&tq, &TopDocs::with_limit(1)).unwrap();
if let Some((_, doc_address)) = docs.first() {
let doc: TantivyDocument = searcher.doc_async(*doc_address).await.unwrap();
for (field, value) in doc.field_values() {
if field == b {
let value = value.as_str();
println!("{:#?}", value);
}
}
} else {
println!("not found")
}
}
+65
View File
@@ -0,0 +1,65 @@
//
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use crate::modules::logger::{validate_log_level, LocalTimer};
use crate::modules::settings::cli::SETTINGS;
use crate::modules::settings::dir::DATA_DIR_MANAGER;
use std::sync::OnceLock;
use tracing::level_filters::LevelFilter;
use tracing::Level;
use tracing_appender::non_blocking::{NonBlocking, WorkerGuard};
use tracing_appender::rolling::{RollingFileAppender, Rotation};
use tracing_subscriber::fmt;
use tracing_subscriber::layer::SubscriberExt;
pub static LOG_WORKER_GUARD: OnceLock<Vec<WorkerGuard>> = OnceLock::new();
pub fn setup_file_logger() -> Result<(), tracing::dispatcher::SetGlobalDefaultError> {
validate_log_level(&SETTINGS.bichon_log_level);
let level = SETTINGS.bichon_log_level.parse::<Level>().unwrap();
let with_ansi = SETTINGS.bichon_ansi_logs;
let (server_nonb, server_guard) = server_log_writer();
LOG_WORKER_GUARD.set(vec![server_guard]).unwrap();
let server_layer = fmt::layer()
.with_timer(LocalTimer)
.with_ansi(with_ansi)
.with_level(true)
.with_writer(server_nonb)
.with_target(true);
let subscriber = tracing_subscriber::registry()
.with(LevelFilter::from_level(level))
.with(server_layer);
// Set the combined subscriber as the global default
tracing::subscriber::set_global_default(subscriber)
}
fn server_log_writer() -> (NonBlocking, WorkerGuard) {
let rolling = RollingFileAppender::builder()
.rotation(Rotation::DAILY)
.filename_prefix("server")
.max_log_files(SETTINGS.bichon_max_server_log_files)
.build(DATA_DIR_MANAGER.log_dir.clone())
.expect("failed to initialize rolling file appender");
let (nb, wg) = tracing_appender::non_blocking(rolling);
(nb, wg)
}
+74
View File
@@ -0,0 +1,74 @@
//
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use crate::logger::file::setup_file_logger;
use crate::modules::settings::cli::SETTINGS;
use chrono::Local;
use std::process;
use tracing::Level;
use tracing_subscriber::fmt::{format::Writer, time::FormatTime};
mod file;
struct LocalTimer;
impl FormatTime for LocalTimer {
fn format_time(&self, w: &mut Writer<'_>) -> std::fmt::Result {
write!(w, "{}", Local::now().format("%Y-%m-%dT%H:%M:%S%.3f%:z"))
}
}
pub fn initialize_logging() {
if SETTINGS.bichon_log_to_file {
setup_file_logger().unwrap();
} else {
setup_stdout_logger().unwrap();
}
}
fn setup_stdout_logger() -> Result<(), tracing::dispatcher::SetGlobalDefaultError> {
validate_log_level(&SETTINGS.bichon_log_level);
let level = SETTINGS.bichon_log_level.parse::<Level>().unwrap();
let with_ansi = SETTINGS.bichon_ansi_logs;
let format = tracing_subscriber::fmt::format()
.with_level(true)
.with_target(true)
.with_timer(LocalTimer);
let subscriber = tracing_subscriber::fmt()
.with_max_level(level)
.with_ansi(with_ansi)
.with_writer(std::io::stdout)
.event_format(format)
.finish();
tracing::subscriber::set_global_default(subscriber)
}
fn validate_log_level(value: &String) {
if value.parse::<Level>().is_err() {
eprintln!(
"Invalid log level specified. Use one of: error, warn, info, debug, trace.
The log level you currently specified is 'rustmailer_log_level'='{}'",
value
);
process::exit(1);
}
}
+99
View File
@@ -0,0 +1,99 @@
//
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use crate::modules::account::migration::{AccountModel, AccountType};
use crate::modules::cache::imap::mailbox::{Attribute, AttributeEnum, MailBox};
use crate::modules::context::executors::MAIL_CONTEXT;
use crate::modules::error::code::ErrorCode;
use crate::modules::error::{BichonError, BichonResult};
use crate::modules::utils::create_hash;
use crate::raise_error;
use async_imap::types::Name;
pub async fn get_account_mailboxes(account_id: u64, remote: bool) -> BichonResult<Vec<MailBox>> {
let account = AccountModel::check_account_active(account_id).await?;
if remote {
if matches!(account.account_type, AccountType::IMAP) {
request_imap_all_mailbox_list(account_id).await
} else {
return Err(raise_error!(
"The 'remote' option can only be used with IMAP accounts.".into(),
ErrorCode::InvalidParameter
));
}
} else {
MailBox::list_all(account_id).await
}
}
pub async fn request_imap_all_mailbox_list(account_id: u64) -> BichonResult<Vec<MailBox>> {
let executor = MAIL_CONTEXT.imap(account_id).await?;
let names = executor.list_all_mailboxes().await?;
convert_names_to_mailboxes(account_id, names.iter()).await
}
fn contains_no_select(attributes: &[Attribute]) -> bool {
attributes
.iter()
.any(|attr| attr.attr == AttributeEnum::NoSelect)
}
pub async fn convert_names_to_mailboxes(
account_id: u64,
names: impl IntoIterator<Item = &Name>,
) -> BichonResult<Vec<MailBox>> {
// Preallocate enough space in the vector to avoid multiple reallocations
let mut tasks = Vec::new();
for name in names.into_iter() {
// Convert the name into a MailBox structure
let mailbox_name = name.name().to_string();
let mut mailbox: MailBox = name.into();
if contains_no_select(&mailbox.attributes) {
continue;
}
mailbox.account_id = account_id;
mailbox.id = create_hash(account_id, &mailbox.name);
let task: tokio::task::JoinHandle<Result<MailBox, BichonError>> =
tokio::spawn(async move {
let executor = MAIL_CONTEXT.imap(account_id).await?;
let mx = executor.examine_mailbox(mailbox_name.as_str()).await?;
// Update the mailbox status information
mailbox.exists = mx.exists; // Number of messages in the mailbox
mailbox.unseen = mx.unseen; // Number of unseen messages
mailbox.uid_next = mx.uid_next; // Next unique identifier to be assigned
mailbox.uid_validity = mx.uid_validity; // Validity of the UIDs
Ok(mailbox)
});
tasks.push(task);
}
let mut mailboxes = Vec::new();
for task in tasks {
match task.await {
Ok(Ok(mailbox)) => mailboxes.push(mailbox),
Ok(Err(err)) => return Err(err), // Handle mailbox-level errors
Err(e) => return Err(raise_error!(format!("{:#?}", e), ErrorCode::InternalError)), // Handle task-level panics or errors
}
}
Ok(mailboxes)
}
+20
View File
@@ -0,0 +1,20 @@
//
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
pub mod list;
+147
View File
@@ -0,0 +1,147 @@
//
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use crate::base64_encode;
use crate::modules::account::migration::AccountModel;
use crate::modules::error::code::ErrorCode;
use crate::modules::indexer::manager::EML_INDEX_MANAGER;
use crate::{modules::error::BichonResult, raise_error};
use mail_parser::{MessageParser, MimeHeaders};
use poem_openapi::Object;
use serde::{Deserialize, Serialize};
/// Represents metadata of an attachment in a Gmail message.
///
/// This struct stores information required to identify, download,
/// and render an attachment, including inline images embedded
/// in HTML emails.
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize, Object)]
pub struct AttachmentInfo {
/// MIME content type of the attachment (e.g., `image/png`, `application/pdf`).
pub file_type: String,
/// Whether the attachment is marked as inline (true) or a regular file (false).
pub inline: bool,
/// Original filename of the attachment, if provided.
pub filename: String,
/// Size of the attachment in bytes.
pub size: usize,
pub content_id: Option<String>,
}
/// Represents the content of an email message in both plain text and HTML formats.
///
/// This struct contains optional fields for plain text and HTML versions of
/// the email message body. At least one of them may be present.
///
/// # Fields
///
/// - `plain`: The plain text version of the message, if available.
/// - `html`: The HTML version of the message, if available.
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize, Object)]
pub struct FullMessageContent {
/// Optional plain text version of the message.
pub text: Option<String>,
/// Optional HTML version of the message.
pub html: Option<String>,
// all Attachments include inline attachments
pub attachments: Option<Vec<AttachmentInfo>>,
}
pub async fn retrieve_email_content(
account_id: u64,
id: u64,
) -> BichonResult<FullMessageContent> {
AccountModel::check_account_active(account_id).await?;
let eml = EML_INDEX_MANAGER
.get(account_id, id)
.await?
.ok_or_else(|| {
raise_error!(
format!(
"Email record not found: account_id={} id={}",
account_id, id
),
ErrorCode::ResourceNotFound
)
})?;
let message = MessageParser::default().parse(&eml).ok_or_else(|| {
raise_error!(
format!(
"Failed to parse EML data (id={}) — the message may be corrupted.",
id
),
ErrorCode::InternalError
)
})?;
let mut html: Option<String> = message.body_html(0).map(|cow| cow.into_owned());
let text: Option<String> = message.body_text(0).map(|cow| cow.into_owned());
let mut attachments = Vec::new();
for attachment in message.attachments() {
let content_type = attachment.content_type().ok_or_else(|| {
raise_error!(
format!("Attachment is missing Content-Type (email id={})", id),
ErrorCode::InternalError
)
})?;
let filename = attachment
.attachment_name()
.map(|name| name.to_string())
.unwrap_or_else(|| format!("email{}_attachment{}", id, attachment.raw_body_offset()));
let disposition = attachment.content_disposition();
let file_type = format!(
"{}/{}",
content_type.c_type.as_ref(),
content_type.c_subtype.as_deref().unwrap_or("")
);
let inline = disposition.map(|d| d.is_inline()).unwrap_or(false);
if inline {
if let Some(html1) = html.as_deref() {
if let Some(cid) = attachment.content_id() {
if html1.contains(cid) {
let data = attachment.contents();
let base64_encoded = base64_encode!(data);
let html_content = html1.replace(
&format!("cid:{}", cid),
&format!("data:{};base64,{}", file_type, base64_encoded),
);
html = Some(html_content);
}
}
}
}
attachments.push(AttachmentInfo {
filename,
size: attachment.len(),
inline,
file_type,
content_id: attachment.content_id().map(Into::into),
});
}
Ok(FullMessageContent {
text,
html,
attachments: Some(attachments),
})
}
+31
View File
@@ -0,0 +1,31 @@
//
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use crate::modules::error::BichonResult;
use crate::modules::indexer::manager::{EML_INDEX_MANAGER, ENVELOPE_INDEX_MANAGER};
use std::collections::HashMap;
pub async fn delete_messages_impl(request: HashMap<u64, Vec<u64>>) -> BichonResult<()> {
EML_INDEX_MANAGER
.delete_email_multi_account(&request)
.await?;
ENVELOPE_INDEX_MANAGER
.delete_envelopes_multi_account(&request)
.await
}
+69
View File
@@ -0,0 +1,69 @@
//
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use crate::{
modules::{
account::migration::AccountModel,
error::{code::ErrorCode, BichonResult},
indexer::{envelope::Envelope, manager::ENVELOPE_INDEX_MANAGER},
rest::response::DataPage,
},
raise_error,
};
pub async fn list_messages_impl(
account_id: u64,
mailbox_id: u64,
page: u64,
page_size: u64,
) -> BichonResult<DataPage<Envelope>> {
AccountModel::check_account_active(account_id).await?;
validate_pagination_params(page, page_size)?;
ENVELOPE_INDEX_MANAGER
.list_mailbox_envelopes(account_id, mailbox_id, page, page_size, true)
.await
}
fn validate_pagination_params(page: u64, page_size: u64) -> BichonResult<()> {
if page == 0 || page_size == 0 {
return Err(raise_error!(
"Both page and page_size must be greater than 0.".into(),
ErrorCode::InvalidParameter
));
}
if page_size > 500 {
return Err(raise_error!(
"The page_size exceeds the maximum allowed limit of 500.".into(),
ErrorCode::InvalidParameter
));
}
Ok(())
}
pub async fn get_thread_messages(
account_id: u64,
thread_id: u64,
page: u64,
page_size: u64,
) -> BichonResult<DataPage<Envelope>> {
AccountModel::check_account_active(account_id).await?;
ENVELOPE_INDEX_MANAGER
.list_thread_envelopes(account_id, thread_id, page, page_size, true)
.await
}
+24
View File
@@ -0,0 +1,24 @@
//
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
pub mod content;
pub mod delete;
pub mod list;
pub mod search;
pub mod tags;
+80
View File
@@ -0,0 +1,80 @@
//
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use poem_openapi::Object;
use serde::{Deserialize, Serialize};
use crate::{
modules::{
error::{code::ErrorCode, BichonResult},
indexer::{envelope::Envelope, manager::ENVELOPE_INDEX_MANAGER},
rest::response::DataPage,
},
raise_error,
};
#[derive(Debug, Clone, Default, Eq, PartialEq, Serialize, Deserialize, Object)]
pub struct SearchFilter {
pub text: Option<String>,
pub from: Option<String>,
pub to: Option<String>,
pub cc: Option<String>,
pub bcc: Option<String>,
pub since: Option<i64>,
pub before: Option<i64>,
pub account_id: Option<u64>,
pub mailbox_id: Option<u64>,
pub min_size: Option<u64>,
pub max_size: Option<u64>,
pub message_id: Option<String>,
pub has_attachment: Option<bool>,
pub attachment_name: Option<String>,
pub tags: Option<Vec<String>>,
}
#[derive(Debug, Clone, Default, Eq, PartialEq, Serialize, Deserialize, Object)]
pub struct SearchRequest {
filter: SearchFilter,
page: u64,
page_size: u64,
}
impl SearchRequest {
pub fn validate(&self) -> BichonResult<()> {
if self.page == 0 || self.page_size == 0 {
return Err(raise_error!(
"Both page and page_size must be greater than 0.".into(),
ErrorCode::InvalidParameter
));
}
if self.page_size > 500 {
return Err(raise_error!(
"The page_size exceeds the maximum allowed limit of 500.".into(),
ErrorCode::InvalidParameter
));
}
Ok(())
}
}
pub async fn search_messages_impl(request: SearchRequest) -> BichonResult<DataPage<Envelope>> {
request.validate()?;
ENVELOPE_INDEX_MANAGER
.search(request.filter, request.page, request.page_size, true)
.await
}
+35
View File
@@ -0,0 +1,35 @@
//
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use std::collections::HashMap;
use poem_openapi::Object;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Default, Eq, PartialEq, Serialize, Deserialize, Object)]
pub struct UpdateTagsRequest {
pub updates: HashMap<u64, Vec<u64>>, // account_id -> envelope_ids
pub tags: Vec<String>,
}
#[derive(Debug, Clone, Default, Eq, PartialEq, Serialize, Deserialize, Object)]
pub struct TagCount {
pub tag: String,
pub count: u64,
}
+40
View File
@@ -0,0 +1,40 @@
//
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
pub mod account;
pub mod autoconfig;
pub mod cache;
pub mod common;
pub mod context;
pub mod dashboard;
pub mod database;
pub mod envelope;
pub mod error;
pub mod imap;
pub mod indexer;
pub mod logger;
pub mod mailbox;
pub mod message;
pub mod oauth2;
pub mod rest;
pub mod settings;
pub mod tasks;
pub mod token;
pub mod utils;
pub mod version;
+275
View File
@@ -0,0 +1,275 @@
//
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use crate::{
encrypt, id,
modules::{
database::{
delete_impl, insert_impl, manager::DB_MANAGER, paginate_query_primary_scan_all_impl,
secondary_find_impl, update_impl,
},
error::{code::ErrorCode, BichonResult},
rest::response::DataPage,
},
raise_error, utc_now,
};
use native_db::*;
use native_model::{native_model, Model};
use poem_openapi::Object;
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
/// Represents the OAuth2 configuration for a client, including initialization and runtime values.
#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize, Object)]
#[native_model(id = 5, version = 1)]
#[native_db(primary_key(pk -> String))]
pub struct OAuth2 {
/// A unique identifier for the OAuth2 configuration.
#[secondary_key(unique)]
pub id: u64,
/// A description of what this configuration is used for.
pub description: Option<String>,
/// The client ID used for authenticating the application with the OAuth2 provider.
pub client_id: String,
/// The client secret used in conjunction with the client ID.
///
/// Users should provide a plaintext secret.
/// The server will encrypt it using AES-256-GCM and securely store it.
/// The plaintext secret is never stored, so users must ensure it is valid for OAuth2 authentication.
pub client_secret: String,
/// The URL to redirect users to for OAuth2 authorization.
pub auth_url: String,
/// The URL to exchange authorization codes for access tokens.
pub token_url: String,
/// The URI where the OAuth2 provider will redirect to after authorization.
pub redirect_uri: String,
/// The scopes of access that are being requested (e.g., email, profile).
pub scopes: Option<Vec<String>>,
/// Any additional parameters to include in the OAuth2 requests (e.g., access_type, prompt).
pub extra_params: Option<BTreeMap<String, String>>,
/// Indicates whether this configuration is enabled or disabled.
pub enabled: bool,
/// route OAuth through proxy (when direct access is blocked)
pub use_proxy: Option<u64>,
/// The timestamp when the configuration was created, in milliseconds since the Unix epoch.
pub created_at: i64,
/// The timestamp when the configuration was last updated, in milliseconds since the Unix epoch.
pub updated_at: i64,
}
impl OAuth2 {
fn pk(&self) -> String {
format!("{}_{}", &self.created_at, &self.id)
}
pub fn new(request: OAuth2CreateRequest) -> BichonResult<Self> {
let request = request.encrypt()?;
Ok(OAuth2 {
id: id!(64),
description: request.description,
client_id: request.client_id,
client_secret: request.client_secret,
auth_url: request.auth_url,
token_url: request.token_url,
redirect_uri: request.redirect_uri,
scopes: request.scopes,
extra_params: request.extra_params,
enabled: request.enabled,
created_at: utc_now!(),
updated_at: utc_now!(),
use_proxy: request.use_proxy,
})
}
pub async fn save(&self) -> BichonResult<()> {
insert_impl(DB_MANAGER.meta_db(), self.to_owned()).await?;
Ok(())
}
pub async fn paginate_list(
page: Option<u64>,
page_size: Option<u64>,
desc: Option<bool>,
) -> BichonResult<DataPage<OAuth2>> {
paginate_query_primary_scan_all_impl(DB_MANAGER.meta_db(), page, page_size, desc)
.await
.map(DataPage::from)
}
pub async fn get(id: u64) -> BichonResult<Option<OAuth2>> {
secondary_find_impl(DB_MANAGER.meta_db(), OAuth2Key::id, id).await
}
pub async fn delete(id: u64) -> BichonResult<()> {
delete_impl(DB_MANAGER.meta_db(), move |rw| {
rw.get()
.secondary::<OAuth2>(OAuth2Key::id, id)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
.ok_or_else(|| {
raise_error!(
format!(
"The oauth2 entity with id={id} that you want to delete was not found."
),
ErrorCode::ResourceNotFound
)
})
})
.await
}
pub async fn update(id: u64, request: OAuth2UpdateRequest) -> BichonResult<()> {
update_impl(
DB_MANAGER.meta_db(),
move |rw| {
rw.get()
.secondary::<OAuth2>(OAuth2Key::id, id)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
.ok_or_else(|| {
raise_error!(
format!("The oauth2 entity with id={id} that you want to modify was not found."),
ErrorCode::ResourceNotFound
)
})
},
|current| apply_update(current, request),
)
.await?;
Ok(())
}
}
#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize, Object)]
pub struct OAuth2CreateRequest {
/// A description of what this configuration is used for.
pub description: Option<String>,
/// The client ID used for authenticating the application with the OAuth2 provider.
pub client_id: String,
/// The client secret used in conjunction with the client ID.
pub client_secret: String,
/// The URL to redirect users to for OAuth2 authorization.
pub auth_url: String,
/// The URL to exchange authorization codes for access tokens.
pub token_url: String,
/// The URI where the OAuth2 provider will redirect to after authorization.
pub redirect_uri: String,
/// The scopes of access that are being requested (e.g., email, profile).
pub scopes: Option<Vec<String>>,
/// Any additional parameters to include in the OAuth2 requests (e.g., access_type, prompt).
pub extra_params: Option<BTreeMap<String, String>>,
/// Indicates whether this configuration is enabled or disabled.
pub enabled: bool,
/// route OAuth through proxy (when direct access is blocked)
pub use_proxy: Option<u64>,
}
impl OAuth2CreateRequest {
pub fn encrypt(self) -> BichonResult<Self> {
Ok(Self {
description: self.description,
client_id: self.client_id,
client_secret: encrypt!(&self.client_secret)?,
auth_url: self.auth_url,
token_url: self.token_url,
redirect_uri: self.redirect_uri,
scopes: self.scopes,
extra_params: self.extra_params,
enabled: self.enabled,
use_proxy: self.use_proxy,
})
}
}
#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize, Object)]
pub struct OAuth2UpdateRequest {
/// A description of what this configuration is used for.
pub description: Option<String>,
/// The client ID used for authenticating the application with the OAuth2 provider.
pub client_id: Option<String>,
/// The client secret used in conjunction with the client ID.
pub client_secret: Option<String>,
/// The URL to redirect users to for OAuth2 authorization.
pub auth_url: Option<String>,
/// The URL to exchange authorization codes for access tokens.
pub token_url: Option<String>,
/// The URI where the OAuth2 provider will redirect to after authorization.
pub redirect_uri: Option<String>,
/// The scopes of access that are being requested (e.g., email, profile).
pub scopes: Option<Vec<String>>,
/// Any additional parameters to include in the OAuth2 requests (e.g., access_type, prompt).
pub extra_params: Option<BTreeMap<String, String>>,
/// Indicates whether this configuration is enabled or disabled.
pub enabled: Option<bool>,
/// route OAuth through proxy (when direct access is blocked)
pub use_proxy: Option<u64>,
}
fn apply_update(old: &OAuth2, request: OAuth2UpdateRequest) -> BichonResult<OAuth2> {
let mut new = old.clone();
if request.description.is_some() {
new.description = request.description;
}
if let Some(client_id) = request.client_id {
new.client_id = client_id;
}
if let Some(client_secret) = request.client_secret {
new.client_secret = encrypt!(&client_secret)?;
}
if let Some(auth_url) = request.auth_url {
new.auth_url = auth_url;
}
if let Some(token_url) = request.token_url {
new.token_url = token_url;
}
if let Some(redirect_uri) = request.redirect_uri {
new.redirect_uri = redirect_uri;
}
if request.scopes.is_some() {
new.scopes = request.scopes;
}
if request.extra_params.is_some() {
new.extra_params = request.extra_params;
}
if let Some(enabled) = request.enabled {
new.enabled = enabled;
}
if let Some(use_proxy) = request.use_proxy {
new.use_proxy = Some(use_proxy);
}
new.updated_at = utc_now!();
Ok(new)
}
+295
View File
@@ -0,0 +1,295 @@
//
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use crate::modules::error::code::ErrorCode;
use crate::modules::error::BichonResult;
use crate::modules::oauth2::{
entity::OAuth2, pending::OAuth2PendingEntity, token::OAuth2AccessToken,
};
use crate::modules::settings::proxy::Proxy;
use crate::{decrypt, encrypt, raise_error};
use oauth2::{
basic::BasicClient, AuthUrl, AuthorizationCode, ClientId, ClientSecret, CsrfToken,
PkceCodeChallenge, PkceCodeVerifier, RedirectUrl, RefreshToken, Scope, TokenResponse, TokenUrl,
};
use poem_openapi::Object;
use serde::{Deserialize, Serialize};
pub type OAuth2Client = oauth2::Client<
oauth2::StandardErrorResponse<oauth2::basic::BasicErrorResponseType>,
oauth2::StandardTokenResponse<oauth2::EmptyExtraTokenFields, oauth2::basic::BasicTokenType>,
oauth2::StandardTokenIntrospectionResponse<
oauth2::EmptyExtraTokenFields,
oauth2::basic::BasicTokenType,
>,
oauth2::StandardRevocableToken,
oauth2::StandardErrorResponse<oauth2::RevocationErrorResponseType>,
oauth2::EndpointSet,
oauth2::EndpointNotSet,
oauth2::EndpointNotSet,
oauth2::EndpointNotSet,
oauth2::EndpointSet,
>;
#[derive(Debug, Clone, Default, Eq, PartialEq, Serialize, Deserialize, Object)]
pub struct AuthorizeUrlRequest {
/// The ID of the account for which the authorization URL is generated.
pub account_id: u64,
/// The name of the OAuth2 configuration to use for generating the authorization URL.
pub oauth2_id: u64,
}
pub struct OAuth2Flow {
pub oauth2_id: u64,
}
impl OAuth2Flow {
pub fn new(oauth2_id: u64) -> Self {
Self { oauth2_id }
}
pub async fn authorize_url(&self, account_id: u64) -> BichonResult<String> {
// Fetch OAuth2 entity or return a custom error if not found
let entity = self.fetch_oauth2_entity().await?;
if !entity.enabled {
return Err(raise_error!(
format!(
"OAuth2 authentication is disabled for this client '{}'.",
self.oauth2_id
),
ErrorCode::OAuth2ItemDisabled
));
}
// Create and configure the OAuth2 client
let client = self.build_oauth2_client(&entity)?;
// Generate PKCE challenge and verifier
let (pkce_code_challenge, pkce_code_verifier) = PkceCodeChallenge::new_random_sha256();
// Build the authorization URL request
let mut request = client
.authorize_url(CsrfToken::new_random)
.set_pkce_challenge(pkce_code_challenge)
.add_scopes(
entity
.scopes
.unwrap_or(Vec::new())
.into_iter()
.map(Scope::new),
);
// Add extra parameters
if let Some(extra_params) = &entity.extra_params {
for (name, value) in extra_params {
request = request.add_extra_param(name.clone(), value.clone());
}
}
// Extract authorization URL and CSRF state
let (authorize_url, csrf_state) = request.url();
// Save the pending OAuth2 state
self.save_pending_oauth2_state(
account_id,
csrf_state.secret(),
pkce_code_verifier.secret(),
)
.await?;
// Return the authorization URL
Ok(authorize_url.to_string())
}
pub async fn fetch_save_access_token(
&self,
account_id: u64,
code_verifier: &str,
code: &str,
) -> BichonResult<()> {
let entity = self.fetch_oauth2_entity().await?;
let client = self.build_oauth2_client(&entity)?;
let http_client = build_http_client(entity.use_proxy).await?;
let token_response = client
.exchange_code(AuthorizationCode::new(code.to_owned()))
.set_pkce_verifier(PkceCodeVerifier::new(code_verifier.to_owned()))
.request_async(&http_client)
.await
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::HttpResponseError))?;
let access_token = token_response.access_token().secret().to_owned();
let refresh_token = token_response
.refresh_token()
.ok_or_else(|| {
raise_error!(
"Missing refresh token in the token response.".into(),
ErrorCode::MissingRefreshToken
)
})?
.secret()
.to_owned();
self.save_oauth2_entity(account_id, access_token, refresh_token)
.await?;
Ok(())
}
async fn save_oauth2_entity(
&self,
account_id: u64,
access_token: String,
refresh_token: String,
) -> BichonResult<()> {
let token =
OAuth2AccessToken::create(account_id, self.oauth2_id, access_token, refresh_token)?;
token.save_or_update().await
}
async fn update_oauth2_entity(
&self,
account_id: u64,
access_token: String,
refresh_token: String,
) -> BichonResult<()> {
OAuth2AccessToken::set_access_token(
account_id,
encrypt!(&access_token)?,
encrypt!(&refresh_token)?,
)
.await
}
pub async fn refresh_access_token(&self, token: &OAuth2AccessToken) -> BichonResult<()> {
let entity = self.fetch_oauth2_entity().await?;
if !entity.enabled {
OAuth2AccessToken::delete_by_oauth2_id(token.oauth2_id).await?;
return Err(raise_error!(
"OAuth2 authentication is disabled for this client".into(),
ErrorCode::OAuth2ItemDisabled
));
}
let client = self.build_oauth2_client(&entity)?;
let http_client = build_http_client(entity.use_proxy).await?;
let refresh_token = token.refresh_token.clone().ok_or_else(|| {
raise_error!(
"refresh token is null".into(),
ErrorCode::MissingRefreshToken
)
})?;
let refresh_response = client
.exchange_refresh_token(&RefreshToken::new(refresh_token.clone()))
.add_scopes(
entity
.scopes
.unwrap_or(Vec::new())
.into_iter()
.map(Scope::new),
)
.request_async(&http_client)
.await
.map_err(|e| {
raise_error!(
format!(
"Failed to retrieve refresh token response: {}",
e.to_string()
),
ErrorCode::HttpResponseError
)
})?;
let access_token = refresh_response.access_token().secret().to_owned();
let new_refresh_token = refresh_response
.refresh_token()
.map(|r| r.secret().to_owned())
.unwrap_or_else(|| refresh_token.clone());
self.update_oauth2_entity(token.account_id, access_token, new_refresh_token)
.await?;
Ok(())
}
// Helper function to fetch the OAuth2 entity
async fn fetch_oauth2_entity(&self) -> BichonResult<OAuth2> {
OAuth2::get(self.oauth2_id).await?.ok_or_else(|| {
raise_error!(
format!("OAuth2 entity with id '{}' not found", self.oauth2_id),
ErrorCode::ResourceNotFound
)
})
}
// Helper function to build the OAuth2 client
fn build_oauth2_client(&self, entity: &OAuth2) -> BichonResult<OAuth2Client> {
let auth_url = AuthUrl::new(entity.auth_url.clone())
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InvalidParameter))?;
let token_url = TokenUrl::new(entity.token_url.clone())
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InvalidParameter))?;
let redirect_uri = RedirectUrl::new(entity.redirect_uri.clone())
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InvalidParameter))?;
// Create and return the OAuth2 client
let client = BasicClient::new(ClientId::new(entity.client_id.clone()))
.set_client_secret(ClientSecret::new(decrypt!(&entity.client_secret)?))
.set_auth_uri(auth_url)
.set_token_uri(token_url)
.set_redirect_uri(redirect_uri);
Ok(client)
}
// Helper function to save the pending OAuth2 state
async fn save_pending_oauth2_state(
&self,
account_id: u64,
csrf_state: &str,
pkce_code_verifier: &str,
) -> BichonResult<()> {
OAuth2PendingEntity::new(
self.oauth2_id,
account_id,
csrf_state.to_owned(),
pkce_code_verifier.to_owned(),
)
.save()
.await
}
}
// Helper function to build the HTTP client
async fn build_http_client(use_proxy: Option<u64>) -> BichonResult<reqwest::Client> {
if let Some(proxy_id) = use_proxy {
let proxy = Proxy::get(proxy_id).await?;
return oauth2::reqwest::ClientBuilder::new()
.redirect(oauth2::reqwest::redirect::Policy::none())
.proxy(reqwest::Proxy::all(&proxy.url).map_err(|e| {
raise_error!(
format!(
"Failed to configure SOCKS5 proxy ({}): {:#?}. Please check",
&proxy.url, e
),
ErrorCode::InternalError
)
})?)
.build()
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError));
}
oauth2::reqwest::ClientBuilder::new()
.redirect(oauth2::reqwest::redirect::Policy::none())
.build()
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))
}
+25
View File
@@ -0,0 +1,25 @@
//
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
pub mod entity;
pub mod flow;
pub mod pending;
pub mod refresh;
pub mod task;
pub mod token;
+138
View File
@@ -0,0 +1,138 @@
//
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use crate::{
modules::{
database::{
batch_delete_impl, delete_impl, async_find_impl, insert_impl, manager::DB_MANAGER,
},
error::{code::ErrorCode, BichonResult},
},
raise_error, utc_now,
};
use itertools::Itertools;
use native_db::*;
use native_model::{native_model, Model};
use serde::{Deserialize, Serialize};
const EXPIRATION_DURATION_MS: i64 = 24 * 60 * 60 * 1000;
#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
#[native_model(id = 6, version = 1)]
#[native_db]
pub struct OAuth2PendingEntity {
/// Unique identifier for the OAuth2 request record
pub oauth2_id: u64,
pub account_id: u64,
/// CSRF protection state parameter used to verify the integrity of the authorization request
#[primary_key]
pub state: String,
/// PKCE code verifier used in the authorization code exchange process to ensure security
pub code_verifier: String,
/// Timestamp when the OAuth2 request was created, used to determine request expiration
pub created_at: i64,
}
impl OAuth2PendingEntity {
pub fn new(
oauth2_id: u64,
account_id: u64,
state: String,
code_verifier: String,
) -> Self {
Self {
oauth2_id,
account_id,
state,
code_verifier,
created_at: utc_now!(),
}
}
pub async fn save(&self) -> BichonResult<()> {
insert_impl(DB_MANAGER.meta_db(), self.to_owned()).await
}
pub async fn delete(state: &str) -> BichonResult<()> {
let state = state.to_string();
delete_impl(DB_MANAGER.meta_db(), move |rw| {
rw.get().primary::<OAuth2PendingEntity>(state.clone())
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
.ok_or_else(|| raise_error!(format!(
"The oauth2 pending entity with state={state} that you want to delete was not found."
), ErrorCode::ResourceNotFound))
}).await
}
pub async fn clean() -> BichonResult<()> {
batch_delete_impl(DB_MANAGER.meta_db(), |rw| {
let all: Vec<OAuth2PendingEntity> = rw
.scan()
.primary()
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
.all()
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
.try_collect()
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
let now = utc_now!();
let to_delete: Vec<OAuth2PendingEntity> = all
.into_iter()
.filter(|e| now - e.created_at > EXPIRATION_DURATION_MS)
.collect();
Ok(to_delete)
})
.await?;
Ok(())
}
pub async fn get(state: &str) -> BichonResult<Option<OAuth2PendingEntity>> {
let entity =
async_find_impl::<OAuth2PendingEntity>(DB_MANAGER.meta_db(), state.to_string())
.await?;
match entity {
Some(entity) => {
let state = state.to_string();
if utc_now!() - entity.created_at > EXPIRATION_DURATION_MS {
delete_impl(DB_MANAGER.meta_db(), move |rw| {
rw.get()
.primary::<OAuth2PendingEntity>(state)
.map_err(|e| {
raise_error!(format!("{:#?}", e), ErrorCode::InternalError)
})?
.ok_or_else(|| {
raise_error!(
"OAuth2 pending entity not found".into(),
ErrorCode::ResourceNotFound
)
})
})
.await?;
return Ok(None);
}
Ok(Some(entity))
}
None => Ok(None),
}
}
}
+91
View File
@@ -0,0 +1,91 @@
//
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use crate::modules::common::periodic::PeriodicTask;
use crate::modules::context::RustMailTask;
use crate::modules::oauth2::token::EXTERNAL_OAUTH_APP_ID;
use crate::modules::oauth2::{flow::OAuth2Flow, token::OAuth2AccessToken};
use crate::utc_now;
use std::time::Duration;
use tracing::{debug, error, info};
const TASK_INTERVAL: Duration = Duration::from_secs(60); // Interval set to 1 minute
const FIFTEEN_MINUTES: Duration = Duration::from_secs(45 * 60);
///This task cleans up expired OAuth2 pending authorizations that haven't been completed by users in a timely manner.
pub struct OAuth2RefreshTask;
impl RustMailTask for OAuth2RefreshTask {
fn start() {
let periodic_task = PeriodicTask::new("oauth2-token-refresh-task");
let task = move |_: Option<u64>| {
Box::pin(async move {
debug!("Starting OAuth2 token refresh task");
// Try to retrieve all OAuth2 access tokens
match OAuth2AccessToken::list_all().await {
Ok(all_tokens) => {
let need_refresh: Vec<OAuth2AccessToken> = all_tokens
.into_iter()
.filter(|token| {
((utc_now!() - token.updated_at)
> FIFTEEN_MINUTES.as_millis() as i64)
&& token.oauth2_id != EXTERNAL_OAUTH_APP_ID
}) // Filter tokens older than 15 minutes
.collect();
if need_refresh.is_empty() {
debug!("No expired tokens need to be refreshed.");
} else {
debug!(
"Found {} tokens that need to be refreshed",
need_refresh.len()
);
for token in need_refresh {
tokio::spawn(async move {
let flow = OAuth2Flow::new(token.oauth2_id.clone());
if let Err(error) = flow.refresh_access_token(&token).await {
error!(
"Failed to refresh access token for {}: {}",
token.account_id, error
);
} else {
info!(
"Successfully refreshed access token for {}",
token.account_id
);
}
});
}
}
}
Err(e) => {
// Log the error when retrieving tokens
error!("Failed to fetch OAuth2 tokens: {:?}", e);
}
}
debug!("OAuth2 token refresh task completed");
Ok(())
})
};
periodic_task.start(task, None, TASK_INTERVAL, false, true);
}
}
+43
View File
@@ -0,0 +1,43 @@
//
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use crate::modules::{
common::periodic::PeriodicTask, context::RustMailTask, oauth2::pending::OAuth2PendingEntity,
};
use std::time::Duration;
const TASK_INTERVAL: Duration = Duration::from_secs(6 * 60 * 60);
///This task cleans up expired OAuth2 pending authorizations that haven't been completed by users in a timely manner.
pub struct OAuth2CleanTask;
impl RustMailTask for OAuth2CleanTask {
fn start() {
let periodic_task = PeriodicTask::new("oauth2-pending-task-cleaner");
let task = move |_: Option<u64>| {
Box::pin(async move {
OAuth2PendingEntity::clean().await?;
Ok(())
})
};
periodic_task.start(task, None, TASK_INTERVAL, false, false);
}
}
+271
View File
@@ -0,0 +1,271 @@
//
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use crate::{
decrypt, encrypt,
modules::{
database::{
async_find_impl, delete_impl, insert_impl, list_all_impl, manager::DB_MANAGER,
update_impl, upsert_impl,
},
error::{code::ErrorCode, BichonResult},
oauth2::entity::OAuth2,
},
raise_error, utc_now,
};
use native_db::*;
use native_model::{native_model, Model};
use poem_openapi::Object;
use serde::{Deserialize, Serialize};
pub const EXTERNAL_OAUTH_APP_ID: u64 = 0;
#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize, Object)]
#[native_model(id = 7, version = 1)]
#[native_db]
pub struct OAuth2AccessToken {
/// The ID of the account associated with this access token.
#[primary_key]
pub account_id: u64,
/// The id of the OAuth2 configuration associated with this access token.
#[secondary_key]
pub oauth2_id: u64,
/// The OAuth2 access token used to authenticate requests to the provider.
pub access_token: Option<String>,
/// The OAuth2 refresh token used to obtain new access tokens.
pub refresh_token: Option<String>,
/// The timestamp when the token record was created, in milliseconds since the Unix epoch.
pub created_at: i64,
/// The timestamp when the token record was last updated, in milliseconds since the Unix epoch.
pub updated_at: i64,
}
impl OAuth2AccessToken {
pub fn create(
account_id: u64,
oauth2_id: u64,
access_token: String,
refresh_token: String,
) -> BichonResult<Self> {
Ok(Self {
account_id,
oauth2_id,
access_token: Some(encrypt!(&access_token)?),
refresh_token: Some(encrypt!(&refresh_token)?),
created_at: utc_now!(),
updated_at: utc_now!(),
})
}
pub async fn upsert_external_oauth_token(
account_id: u64,
request: ExternalOAuth2Request,
) -> BichonResult<()> {
let now = utc_now!();
request.validate().await?;
let current = Self::get(account_id).await?;
match current {
Some(mut current) => {
// Update existing record
if let Some(oauth2_id) = request.oauth2_id {
current.oauth2_id = oauth2_id;
}
if let Some(access_token) = request.access_token {
current.access_token = Some(encrypt!(&access_token)?);
}
if let Some(refresh_token) = request.refresh_token {
current.refresh_token = Some(encrypt!(&refresh_token)?);
}
current.updated_at = now;
upsert_impl(DB_MANAGER.meta_db(), current).await?;
}
None => {
// Insert new record
let entity = Self {
account_id,
oauth2_id: request.oauth2_id.unwrap_or(EXTERNAL_OAUTH_APP_ID),
access_token: request
.access_token
.as_ref()
.map(|token| encrypt!(token))
.transpose()?,
refresh_token: request
.refresh_token
.as_ref()
.map(|token| encrypt!(token))
.transpose()?,
created_at: now,
updated_at: now,
};
insert_impl(DB_MANAGER.meta_db(), entity).await?;
}
}
Ok(())
}
// This function may be called multiple times for one account, so we use upsert.
pub async fn save_or_update(&self) -> BichonResult<()> {
upsert_impl(DB_MANAGER.meta_db(), self.clone()).await
}
pub async fn get(account_id: u64) -> BichonResult<Option<OAuth2AccessToken>> {
async_find_impl::<OAuth2AccessToken>(DB_MANAGER.meta_db(), account_id)
.await?
.map(|mut token| {
token.access_token = token.access_token.map(|t| decrypt!(&t)).transpose()?;
token.refresh_token = token.refresh_token.map(|t| decrypt!(&t)).transpose()?;
Ok(token)
})
.transpose()
}
pub async fn list_all() -> BichonResult<Vec<OAuth2AccessToken>> {
list_all_impl::<OAuth2AccessToken>(DB_MANAGER.meta_db())
.await?
.into_iter()
.map(|mut token| {
token.access_token = token.access_token.map(|t| decrypt!(&t)).transpose()?;
token.refresh_token = token.refresh_token.map(|t| decrypt!(&t)).transpose()?;
Ok(token)
})
.collect()
}
pub async fn try_delete(account_id: u64) -> BichonResult<()> {
if Self::get(account_id).await?.is_none() {
return Ok(());
}
delete_impl(DB_MANAGER.meta_db(), move |rw|{
rw.get().primary::<OAuth2AccessToken>(account_id)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
.ok_or_else(|| raise_error!(format!(
"The oauth2 access token entity with account_id={account_id} that you want to delete was not found."
),ErrorCode::ResourceNotFound))
}).await
}
pub async fn delete_by_oauth2_id(oauth2_id: u64) -> BichonResult<()> {
delete_impl(DB_MANAGER.meta_db(), move |rw|{
rw.get().secondary::<OAuth2AccessToken>(OAuth2AccessTokenKey::oauth2_id, oauth2_id)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
.ok_or_else(|| raise_error!(format!(
"The oauth2 access token entity with oauth2_id={oauth2_id} that you want to delete was not found."
),ErrorCode::ResourceNotFound))
}).await
}
pub async fn set_access_token(
account_id: u64,
access_token: String,
refresh_token: String,
) -> BichonResult<()> {
update_impl(DB_MANAGER.meta_db(), move |rw|{
rw.get().primary::<OAuth2AccessToken>(account_id)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
.ok_or_else(|| raise_error!(format!(
"The oauth2 access token entity with account_id={account_id} that you want to modify was not found."
),ErrorCode::ResourceNotFound))
}, |current| {
let mut updated = current.clone();
updated.access_token = Some(access_token);
updated.refresh_token = Some(refresh_token);
updated.updated_at = utc_now!();
Ok(updated)
}).await?;
Ok(())
}
}
#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize, Object)]
pub struct ExternalOAuth2Request {
/// The id of the OAuth2 configuration associated with this access token.
pub oauth2_id: Option<u64>,
/// The OAuth2 access token used to authenticate requests to the provider.
pub access_token: Option<String>,
/// The OAuth2 refresh token used to obtain new access tokens.
pub refresh_token: Option<String>,
}
impl ExternalOAuth2Request {
/// Validates the request.
///
/// Ensures mutual dependency between oauth2_id and refresh_token:
/// - If `refresh_token` is provided, `oauth2_id` must also be present.
/// - If `oauth2_id` is provided, `refresh_token` must also be present.
pub async fn validate(&self) -> BichonResult<()> {
match (self.oauth2_id.is_some(), self.refresh_token.is_some()) {
(true, false) => {
return Err(raise_error!(
"refresh_token must be provided if oauth2_id is set".into(),
ErrorCode::InvalidParameter
));
}
(false, true) => {
return Err(raise_error!(
"oauth2_id must be provided if refresh_token is set".into(),
ErrorCode::InvalidParameter
));
}
_ => {}
}
// Validate that oauth2_id exists in the database if provided
if let Some(oauth2_id) = self.oauth2_id {
let oauth2 = OAuth2::get(oauth2_id).await?;
if oauth2.is_none() {
return Err(raise_error!(
format!("OAuth2 configuration with id {} does not exist", oauth2_id),
ErrorCode::InvalidParameter
));
}
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use crate::modules::oauth2::token::OAuth2AccessToken;
#[tokio::test]
async fn test1() {
let token = OAuth2AccessToken::create(
1000u64,
1020u64,
"access_token".into(),
"refresh_token".into(),
)
.unwrap();
token.save_or_update().await.unwrap();
let token2 = OAuth2AccessToken::get(1000u64).await.unwrap().unwrap();
assert_eq!(token2.access_token, Some("access_token".into()));
assert_eq!(token2.refresh_token, Some("refresh_token".into()));
let tokens = OAuth2AccessToken::list_all().await.unwrap();
assert_eq!(tokens.len(), 1);
let first = tokens.first().unwrap();
assert_eq!(first.access_token, Some("access_token".into()));
assert_eq!(first.refresh_token, Some("refresh_token".into()));
}
}
+156
View File
@@ -0,0 +1,156 @@
//
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use crate::modules::common::auth::ClientContext;
use crate::modules::rest::api::ApiTags;
use crate::modules::rest::ApiResult;
use crate::modules::token::payload::AccessTokenUpdateRequest;
use crate::modules::token::root::set_root_password;
use crate::modules::{
token::payload::AccessTokenCreateRequest,
token::{root::reset_root_token, AccessToken},
};
use poem_openapi::payload::PlainText;
use poem_openapi::{param::Path, payload::Json, OpenApi};
pub struct AccessTokenApi;
#[OpenApi(prefix_path = "/api/v1", tag = "ApiTags::AccessToken")]
impl AccessTokenApi {
/// Lists all access tokens in the system.
///
/// Requires root privileges.
#[oai(
path = "/access-token-list",
method = "get",
operation_id = "list_access_tokens"
)]
async fn list_access_tokens(
&self,
context: ClientContext,
) -> ApiResult<Json<Vec<AccessToken>>> {
context.require_root()?;
Ok(Json(AccessToken::list_all().await?))
}
/// Lists access tokens for a specific account.
///
/// Requires root privileges.
#[oai(
path = "/access-token-list/:account_id",
method = "get",
operation_id = "list_account_access_tokens"
)]
async fn list_account_access_tokens(
&self,
/// The ID of the account whose tokens are to be retrieved.
account_id: Path<u64>,
context: ClientContext,
) -> ApiResult<Json<Vec<AccessToken>>> {
context.require_root()?;
Ok(Json(AccessToken::list_account_tokens(account_id.0).await?))
}
/// Deletes a specific access token.
///
/// Requires root privileges.
#[oai(
path = "/access-token/:token",
method = "delete",
operation_id = "remove_access_token"
)]
async fn remove_access_token(
&self,
/// The access token to be deleted
token: Path<String>,
context: ClientContext,
) -> ApiResult<()> {
context.require_root()?;
Ok(AccessToken::delete(token.0.trim()).await?)
}
/// Creates a new access token.
///
/// Requires root privileges.
#[oai(
path = "/access-token",
method = "post",
operation_id = "create_access_token"
)]
async fn create_access_token(
&self,
context: ClientContext,
/// The request payload
payload: Json<AccessTokenCreateRequest>,
) -> ApiResult<PlainText<String>> {
context.require_root()?;
Ok(PlainText(AccessToken::create(payload.0).await?))
}
/// Updates an existing access token.
///
/// Requires root privileges.
#[oai(
path = "/access-token/:token",
method = "post",
operation_id = "update_access_token"
)]
async fn update_access_token(
&self,
context: ClientContext,
/// The access token to be updated.
token: Path<String>,
/// The request payload.
payload: Json<AccessTokenUpdateRequest>,
) -> ApiResult<()> {
context.require_root()?;
Ok(AccessToken::update(token.0.trim(), payload.0).await?)
}
/// Regenerates the root access token.
///
/// Requires root privileges.
#[oai(
path = "/reset-root-token",
method = "post",
operation_id = "regenerate_root_token"
)]
async fn regenerate_root_token(&self, context: ClientContext) -> ApiResult<PlainText<String>> {
context.require_root()?;
Ok(PlainText(reset_root_token().await?))
}
/// Reset the Root user's password.
///
/// Only callable by an already authenticated Root user.
/// This endpoint updates the Root password to `password_str`
/// and regenerates the `root_token`, invalidating any previous token.
#[oai(
path = "/reset-root-password",
method = "post",
operation_id = "reset_root_password"
)]
async fn reset_root_password(
&self,
password_str: PlainText<String>,
context: ClientContext,
) -> ApiResult<()> {
context.require_root()?;
Ok(set_root_password(password_str.0.trim()).await?)
}
}
+207
View File
@@ -0,0 +1,207 @@
//
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use std::collections::BTreeSet;
use crate::modules::account::payload::{
filter_accessible_accounts, AccountCreateRequest, AccountUpdateRequest, MinimalAccount,
};
use crate::modules::account::state::AccountRunningState;
use crate::modules::account::migration::AccountModel;
use crate::modules::common::auth::ClientContext;
use crate::modules::common::paginated::paginate_vec;
use crate::modules::error::code::ErrorCode;
use crate::modules::rest::api::ApiTags;
use crate::modules::rest::response::DataPage;
use crate::modules::rest::ApiResult;
use crate::modules::token::{AccessToken, AccountInfo};
use crate::raise_error;
use poem::web::Path;
use poem_openapi::param::Query;
use poem_openapi::payload::Json;
use poem_openapi::OpenApi;
pub struct AccountApi;
#[OpenApi(prefix_path = "/api/v1", tag = "ApiTags::Account")]
impl AccountApi {
/// Get account details by account ID
#[oai(
path = "/account/:account_id",
method = "get",
operation_id = "get_account"
)]
async fn get_account(
&self,
/// The account ID to retrieve
account_id: Path<u64>,
context: ClientContext,
) -> ApiResult<Json<AccountModel>> {
let account_id = account_id.0;
context.require_account_access(account_id)?;
Ok(Json(AccountModel::get(account_id).await?))
}
/// Delete an account by ID - WARNING: This permanently removes the account and all associated resources
#[oai(
path = "/account/:account_id",
method = "delete",
operation_id = "remove_account"
)]
async fn remove_account(
&self,
/// The account ID to delete
account_id: Path<u64>,
context: ClientContext,
) -> ApiResult<()> {
let account_id = account_id.0;
context.require_account_access(account_id)?;
Ok(AccountModel::delete(account_id).await?)
}
/// Create a new account
#[oai(path = "/account", method = "post", operation_id = "create_account")]
async fn create_account(
&self,
/// Account creation request payload
payload: Json<AccountCreateRequest>,
context: ClientContext,
) -> ApiResult<Json<AccountModel>> {
let account = AccountModel::create_account(payload.0).await?;
if let Some(access_token) = &context.access_token {
let account_info = AccountInfo {
id: account.id,
email: account.email.clone(),
};
AccessToken::grant_account_access(&access_token.token, account_info).await?;
}
Ok(Json(account))
}
/// Update an existing account
#[oai(
path = "/account/:account_id",
method = "post",
operation_id = "update_account"
)]
async fn update_account(
&self,
/// The account ID to update
account_id: Path<u64>,
/// Account update request payload
payload: Json<AccountUpdateRequest>,
context: ClientContext,
) -> ApiResult<()> {
let account_id = account_id.0;
context.require_account_access(account_id)?;
Ok(AccountModel::update(account_id, payload.0, true).await?)
}
/// List accounts with optional pagination parameters
#[oai(
path = "/accounts",
method = "get",
operation_id = "list_accounts"
)]
async fn list_accounts(
&self,
/// Optional. The page number to retrieve (starting from 1).
page: Query<Option<u64>>,
/// Optional. The number of items per page.
page_size: Query<Option<u64>>,
/// Optional. Whether to sort the list in descending order.
desc: Query<Option<bool>>,
context: ClientContext,
) -> ApiResult<Json<DataPage<AccountModel>>> {
let accessible_accounts = context.accessible_accounts()?;
if accessible_accounts.is_none() {
return Ok(Json(
AccountModel::paginate_list(page.0, page_size.0, desc.0).await?,
));
}
let all_accounts = AccountModel::list_all().await?;
let allowed_ids: BTreeSet<u64> =
accessible_accounts.unwrap().iter().map(|a| a.id).collect();
let mut filtered_accounts: Vec<AccountModel> = all_accounts
.into_iter()
.filter(|acct| allowed_ids.contains(&acct.id))
.collect();
let sort_desc = desc.0.unwrap_or(true);
filtered_accounts.sort_by(|a, b| {
if sort_desc {
b.created_at.cmp(&a.created_at)
} else {
a.created_at.cmp(&b.created_at)
}
});
let page_data =
paginate_vec(&filtered_accounts, page.0, page_size.0).map(DataPage::from)?;
Ok(Json(page_data))
}
/// Get the running state of an account
#[oai(
path = "/account-state/:account_id",
method = "get",
operation_id = "account_state"
)]
async fn account_state(
&self,
/// The account ID to check state for
account_id: Path<u64>,
context: ClientContext,
) -> ApiResult<Json<AccountRunningState>> {
let account_id = account_id.0;
context.require_account_access(account_id)?;
let state = AccountRunningState::get(account_id).await?.ok_or_else(|| {
raise_error!(
"account running state is not found".into(),
ErrorCode::ResourceNotFound
)
})?;
Ok(Json(state))
}
/// Get a minimal list of active accounts for use in selectors when creating account-related resources
///
/// This endpoint provides a lightweight list of accounts containing only essential information (id and name).
/// It's primarily designed for UI selectors/dropdowns when creating or associating resources with accounts.
#[oai(
path = "/minimal-account-list",
method = "get",
operation_id = "minimal_accounts_list"
)]
async fn minimal_accounts_list(
&self,
context: ClientContext,
) -> ApiResult<Json<Vec<MinimalAccount>>> {
let accessible_accounts = context.accessible_accounts()?;
let minimal_list = AccountModel::minimal_list().await?;
let result = match accessible_accounts {
Some(set) => filter_accessible_accounts(&minimal_list, set),
None => minimal_list,
};
Ok(Json(result))
}
}
+55
View File
@@ -0,0 +1,55 @@
//
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use crate::modules::autoconfig::entity::MailServerConfig;
use crate::modules::autoconfig::load::resolve_autoconfig;
use crate::modules::error::code::ErrorCode;
use crate::modules::rest::api::ApiTags;
use crate::modules::rest::ApiResult;
use crate::raise_error;
use poem::web::Path;
use poem_openapi::payload::Json;
use poem_openapi::OpenApi;
pub struct AutoConfigApi;
#[OpenApi(prefix_path = "/api/v1", tag = "ApiTags::AutoConfig")]
impl AutoConfigApi {
/// Retrieve mail server configuration for a given email address
#[oai(
path = "/autoconfig/:email_address",
method = "get",
operation_id = "autoconfig"
)]
async fn autoconfig(
&self,
/// The email address to lookup configuration for
email_address: Path<String>
) -> ApiResult<Json<MailServerConfig>> {
let result = resolve_autoconfig(email_address.0.trim())
.await?
.ok_or_else(|| {
raise_error!(
"Unable to find account configuration information in the backend.".into(),
ErrorCode::ResourceNotFound
)
})?;
Ok(Json(result))
}
}
+60
View File
@@ -0,0 +1,60 @@
//
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use crate::modules::cache::imap::mailbox::MailBox;
use crate::modules::common::auth::ClientContext;
use crate::modules::mailbox::list::get_account_mailboxes;
use crate::modules::rest::api::ApiTags;
use crate::modules::rest::ApiResult;
use poem::web::Path;
use poem_openapi::param::Query;
use poem_openapi::payload::Json;
use poem_openapi::OpenApi;
pub struct MailBoxApi;
#[OpenApi(prefix_path = "/api/v1", tag = "ApiTags::Mailbox")]
impl MailBoxApi {
/// Returns all available mailboxes for the given account.
///
/// - For IMAP/SMTP accounts, this corresponds to folders/mailboxes.
/// - For Gmail API accounts, this corresponds to labels visible via the
/// `list messages` API (serving as mailbox equivalents).
///
/// Both account types support two modes:
/// - Using the local cache of mailboxes/labels.
/// - Querying the remote service directly for the latest state.
#[oai(
path = "/list-mailboxes/:account_id",
method = "get",
operation_id = "list_mailboxes"
)]
async fn list_mailboxes(
&self,
/// The unique identifier of the account.
account_id: Path<u64>,
remote: Query<Option<bool>>,
context: ClientContext,
) -> ApiResult<Json<Vec<MailBox>>> {
let account_id = account_id.0;
context.require_account_access(account_id)?;
let remote = remote.0.unwrap_or(false);
Ok(Json(get_account_mailboxes(account_id, remote).await?))
}
}
+218
View File
@@ -0,0 +1,218 @@
//
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use crate::modules::common::auth::ClientContext;
use crate::modules::indexer::envelope::Envelope;
use crate::modules::indexer::manager::EML_INDEX_MANAGER;
use crate::modules::indexer::manager::ENVELOPE_INDEX_MANAGER;
use crate::modules::message::content::{retrieve_email_content, FullMessageContent};
use crate::modules::message::delete::delete_messages_impl;
use crate::modules::message::list::{get_thread_messages, list_messages_impl};
use crate::modules::message::search::{search_messages_impl, SearchRequest};
use crate::modules::message::tags::TagCount;
use crate::modules::message::tags::UpdateTagsRequest;
use crate::modules::rest::api::ApiTags;
use crate::modules::rest::response::DataPage;
use crate::modules::rest::ApiResult;
use crate::modules::rest::ErrorCode;
use crate::raise_error;
use poem::web::Path;
use poem::Body;
use poem_openapi::param::Query;
use poem_openapi::payload::{Attachment, AttachmentType, Json};
use poem_openapi::OpenApi;
use std::collections::HashMap;
use tantivy::schema::Facet;
pub struct MessageApi;
#[OpenApi(prefix_path = "/api/v1", tag = "ApiTags::Message")]
impl MessageApi {
/// Deletes messages from a mailbox or moves them to the trash for the specified account.
#[oai(
path = "/delete-messages",
method = "post",
operation_id = "delete_messages"
)]
async fn delete_messages(
&self,
/// specifying the mailbox and messages to delete.
payload: Json<HashMap<u64, Vec<u64>>>,
context: ClientContext,
) -> ApiResult<()> {
let request = payload.0;
for account_id in request.keys() {
context.require_account_access(*account_id)?;
}
Ok(delete_messages_impl(request).await?)
}
/// Lists messages in a specified mailbox for the given account.
#[oai(
path = "/list-messages/:account_id",
method = "get",
operation_id = "list_messages"
)]
async fn list_messages(
&self,
account_id: Path<u64>,
mailbox_id: Query<u64>,
page: Query<u64>,
page_size: Query<u64>,
context: ClientContext,
) -> ApiResult<Json<DataPage<Envelope>>> {
let account_id = account_id.0;
let mailbox_id = mailbox_id.0;
context.require_account_access(account_id)?;
Ok(Json(
list_messages_impl(account_id, mailbox_id, page.0, page_size.0).await?,
))
}
/// Lists messages in a specified mailbox for the given account.
#[oai(
path = "/search-messages",
method = "post",
operation_id = "search_messages"
)]
async fn search_messages(
&self,
payload: Json<SearchRequest>,
context: ClientContext,
) -> ApiResult<Json<DataPage<Envelope>>> {
context.require_root()?;
Ok(Json(search_messages_impl(payload.0).await?))
}
/// Get thread's envelopes in a specified mailbox for the given account.
#[oai(
path = "/get-thread-messages/:account_id",
method = "get",
operation_id = "get_thread_messages"
)]
async fn get_thread_messages(
&self,
/// The ID of the account owning the mailbox.
account_id: Path<u64>,
// Thread ID
thread_id: Query<u64>,
/// The page number for pagination (1-based).
page: Query<u64>,
/// The number of messages per page.
page_size: Query<u64>,
context: ClientContext,
) -> ApiResult<Json<DataPage<Envelope>>> {
let account_id = account_id.0;
let thread_id = thread_id.0;
context.require_account_access(account_id)?;
Ok(Json(
get_thread_messages(account_id, thread_id, page.0, page_size.0).await?,
))
}
/// Fetches the content of a specific email for the given account.
#[oai(
path = "/message-content/:account_id",
method = "get",
operation_id = "fetch_message_content"
)]
async fn fetch_message_content(
&self,
account_id: Path<u64>,
id: Query<u64>,
context: ClientContext,
) -> ApiResult<Json<FullMessageContent>> {
let account_id = account_id.0;
context.require_account_access(account_id)?;
Ok(Json(retrieve_email_content(account_id, id.0).await?))
}
/// Fetches the full content of a specific email for the given account.
#[oai(
path = "/download-message/:account_id",
method = "get",
operation_id = "download_message"
)]
async fn download_message(
&self,
account_id: Path<u64>,
id: Query<u64>,
context: ClientContext,
) -> ApiResult<Attachment<Body>> {
let account_id = account_id.0;
context.require_account_access(account_id)?;
let id = id.0;
let reader = EML_INDEX_MANAGER.get_reader(account_id, id).await?;
let body = Body::from_async_read(reader);
let attachment = Attachment::new(body)
.attachment_type(AttachmentType::Attachment)
.filename(format!("{id}.eml"));
Ok(attachment)
}
/// Downloads a specific attachment by filename.
#[oai(
path = "/download-attachment/:account_id",
method = "get",
operation_id = "download_attachment"
)]
async fn download_attachment(
&self,
account_id: Path<u64>,
id: Query<u64>,
name: Query<String>,
context: ClientContext,
) -> ApiResult<Attachment<Body>> {
let account_id = account_id.0;
context.require_account_access(account_id)?;
let email_id = id.0;
let name = name.0.trim();
let reader = EML_INDEX_MANAGER
.get_attachment(account_id, email_id, name)
.await?;
let body = Body::from_async_read(reader);
let attachment = Attachment::new(body)
.attachment_type(AttachmentType::Attachment)
.filename(name);
Ok(attachment)
}
/// Returns all facets in the index along with their document counts.
#[oai(path = "/all-tags", method = "get", operation_id = "get_all_tags")]
async fn get_all_tags(&self) -> ApiResult<Json<Vec<TagCount>>> {
Ok(Json(ENVELOPE_INDEX_MANAGER.get_all_tags().await?))
}
/// Adds or removes facet tags for multiple emails across accounts.
#[oai(
path = "/update-tags",
method = "post",
operation_id = "update_envelope_tags"
)]
async fn update_envelope_tags(&self, req: Json<UpdateTagsRequest>) -> ApiResult<()> {
let req = req.0;
for tag in &req.tags {
Facet::from_text(tag)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InvalidParameter))?;
}
ENVELOPE_INDEX_MANAGER
.update_envelope_tags(req.updates, req.tags)
.await?;
Ok(())
}
}
+74
View File
@@ -0,0 +1,74 @@
//
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use access_token::AccessTokenApi;
use account::AccountApi;
use auto_config::AutoConfigApi;
use mailbox::MailBoxApi;
use message::MessageApi;
use oauth2::OAuth2Api;
use poem_openapi::{OpenApiService, Tags};
use system::SystemApi;
use crate::bichon_version;
pub mod access_token;
pub mod account;
pub mod auto_config;
pub mod mailbox;
pub mod message;
pub mod oauth2;
pub mod system;
#[derive(Tags)]
pub enum ApiTags {
AccessToken,
AutoConfig,
Account,
Mailbox,
OAuth2,
Message,
System,
}
type RustMailOpenApi = (
AccessTokenApi,
AutoConfigApi,
AccountApi,
SystemApi,
MailBoxApi,
OAuth2Api,
MessageApi,
);
pub fn create_openapi_service() -> OpenApiService<RustMailOpenApi, ()> {
OpenApiService::new(
(
AccessTokenApi,
AutoConfigApi,
AccountApi,
SystemApi,
MailBoxApi,
OAuth2Api,
MessageApi,
),
"BichonApi",
bichon_version!(),
)
}
+228
View File
@@ -0,0 +1,228 @@
//
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use crate::modules::common::auth::ClientContext;
use crate::modules::error::code::ErrorCode;
use crate::modules::oauth2::entity::{OAuth2, OAuth2CreateRequest, OAuth2UpdateRequest};
use crate::modules::oauth2::flow::{AuthorizeUrlRequest, OAuth2Flow};
use crate::modules::oauth2::token::{ExternalOAuth2Request, OAuth2AccessToken};
use crate::modules::rest::api::ApiTags;
use crate::modules::rest::response::DataPage;
use crate::modules::rest::ApiResult;
use crate::raise_error;
use poem::web::Path;
use poem_openapi::param::Query;
use poem_openapi::payload::{Json, PlainText};
use poem_openapi::OpenApi;
pub struct OAuth2Api;
#[OpenApi(prefix_path = "/api/v1", tag = "ApiTags::OAuth2")]
impl OAuth2Api {
/// Retrieves the OAuth2 configuration for a specified name.
///
/// Requires root privileges.
/// This endpoint fetches the OAuth2 configuration identified by the given name.
#[oai(
path = "/oauth2/:id",
method = "get",
operation_id = "get_oauth2_config"
)]
async fn get_oauth2_config(
&self,
/// The name of the OAuth2 configuration to retrieve
id: Path<u64>,
context: ClientContext,
) -> ApiResult<Json<OAuth2>> {
context.require_root()?;
let id = id.0;
Ok(Json(OAuth2::get(id).await?.ok_or_else(|| {
raise_error!(
format!("OAuth2 configuration id='{id}' not found"),
ErrorCode::ResourceNotFound
)
})?))
}
/// Deletes an OAuth2 configuration by name.
///
/// Requires root privileges.
/// This endpoint removes the OAuth2 configuration identified by the specified name.
#[oai(
path = "/oauth2/:id",
method = "delete",
operation_id = "remove_oauth2_config"
)]
async fn remove_oauth2_config(
&self,
/// The name of the OAuth2 configuration to retrieve
id: Path<u64>,
context: ClientContext,
) -> ApiResult<()> {
context.require_root()?;
Ok(OAuth2::delete(id.0).await?)
}
/// Creates a new OAuth2 configuration.
///
/// Requires root privileges.
/// This endpoint creates a new OAuth2 configuration based on the provided request data.
#[oai(
path = "/oauth2",
method = "post",
operation_id = "create_oauth2_config"
)]
async fn create_oauth2_config(
&self,
/// A JSON payload containing the details for the new OAuth2 configuration
request: Json<OAuth2CreateRequest>,
context: ClientContext,
) -> ApiResult<()> {
context.require_root()?;
let entity = OAuth2::new(request.0)?;
Ok(entity.save().await?)
}
/// Updates an existing OAuth2 configuration.
///
/// Requires root privileges.
/// This endpoint updates the OAuth2 configuration identified by the specified name
#[oai(
path = "/oauth2/:id",
method = "post",
operation_id = "update_oauth2_config"
)]
async fn update_oauth2_config(
&self,
/// The name of the OAuth2 configuration to update
id: Path<u64>,
/// A JSON payload containing the updated configuration details
payload: Json<OAuth2UpdateRequest>,
context: ClientContext,
) -> ApiResult<()> {
context.require_root()?;
Ok(OAuth2::update(id.0, payload.0).await?)
}
/// Lists OAuth2 configurations with pagination and sorting options.
///
/// This endpoint retrieves a paginated list of OAuth2 configurations, allowing for
/// optional pagination and sorting parameters. It requires root access.
#[oai(
path = "/oauth2-list",
method = "get",
operation_id = "list_oauth2_config"
)]
async fn list_oauth2_config(
&self,
/// Optional. The page number to retrieve (starting from 1).
page: Query<Option<u64>>,
/// Optional. The number of items per page.
page_size: Query<Option<u64>>,
/// Optional. Whether to sort the list in descending order.
desc: Query<Option<bool>>,
context: ClientContext,
) -> ApiResult<Json<DataPage<OAuth2>>> {
context.require_root()?;
Ok(Json(
OAuth2::paginate_list(page.0, page_size.0, desc.0).await?,
))
}
/// Generates an OAuth2 authorization URL for a specific account.
///
/// This endpoint creates an authorization URL for the specified OAuth2 configuration
/// and account ID. It requires root access and returns the URL as plain text.
#[oai(
path = "/oauth2-authorize-url",
method = "post",
operation_id = "create_oauth2_authorize_url"
)]
async fn create_oauth2_authorize_url(
&self,
/// A JSON payload containing the OAuth2 configuration name and account ID.
request: Json<AuthorizeUrlRequest>,
context: ClientContext,
) -> ApiResult<PlainText<String>> {
context.require_root()?;
let request = request.0;
let flow = OAuth2Flow::new(request.oauth2_id);
Ok(PlainText(flow.authorize_url(request.account_id).await?))
}
/// Retrieves OAuth2 access tokens for a specified account.
///
/// This endpoint fetches the OAuth2 access tokens associated with the given account ID.
#[oai(
path = "/oauth2-tokens/:account_id",
method = "get",
operation_id = "get_oauth2_tokens"
)]
async fn get_oauth2_tokens(
&self,
/// The ID of the account to retrieve access tokens for
account_id: Path<u64>,
context: ClientContext,
) -> ApiResult<Json<OAuth2AccessToken>> {
let account = account_id.0;
context.require_account_access(account)?;
Ok(Json(OAuth2AccessToken::get(account).await?.ok_or_else(
|| {
raise_error!(
"OAuth2 access tokens not found".into(),
ErrorCode::ResourceNotFound
)
},
)?))
}
/// Configures an external OAuth2 token for a specified account.
///
/// This endpoint allows two usage modes:
/// 1. If only an `access_token` is provided, RustMailer will store it directly.
/// - In this mode, RustMailer **cannot refresh** the token, since it has no
/// associated OAuth2 configuration or refresh token.
/// - The caller is responsible for periodically updating the access token
/// by calling this endpoint again.
/// 2. If both `oauth2_id` and `refresh_token` are provided, it means the external
/// OAuth2 authorization flow has been completed outside RustMailer.
/// - Since the OAuth2 configuration (including client_id and client_secret)
/// is already stored in RustMailer, the service can use the refresh token
/// to obtain new access tokens automatically.
///
/// Note: The `oauth2_id` must reference a valid OAuth2 configuration
/// already created in RustMailer.
#[oai(
path = "/store-external-oauth2-token/:account_id",
method = "post",
operation_id = "store_external_oauth2_token"
)]
async fn store_external_oauth2_token(
&self,
account_id: Path<u64>,
request: Json<ExternalOAuth2Request>,
context: ClientContext,
) -> ApiResult<()> {
let account = account_id.0;
// Check account access permissions
context.require_account_access(account)?;
OAuth2AccessToken::upsert_external_oauth_token(account, request.0).await?;
Ok(())
}
}
+120
View File
@@ -0,0 +1,120 @@
//
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use crate::modules::common::auth::ClientContext;
use crate::modules::dashboard::DashboardStats;
use crate::modules::error::code::ErrorCode;
use crate::modules::rest::api::ApiTags;
use crate::modules::rest::ApiResult;
use crate::modules::settings::proxy::Proxy;
use crate::modules::version::{fetch_notifications, Notifications};
use crate::raise_error;
use poem_openapi::param::Path;
use poem_openapi::payload::{Json, PlainText};
use poem_openapi::OpenApi;
pub struct SystemApi;
#[OpenApi(prefix_path = "/api/v1", tag = "ApiTags::System")]
impl SystemApi {
/// Retrieves important system notifications for the RustMail service.
///
/// This endpoint returns a consolidated view of all critical system notifications including:
/// - Available version updates
/// - License expiration warnings
#[oai(
method = "get",
path = "/notifications",
operation_id = "get_notifications"
)]
async fn get_notifications(&self) -> ApiResult<Json<Notifications>> {
let notification = fetch_notifications()
.await
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
Ok(Json(notification))
}
/// Get overall dashboard statistics.
///
/// Returns various aggregated metrics about the mail system, such as
/// total email count, total storage size, index usage, top senders,
/// recent activity histogram, and more.
#[oai(
method = "get",
path = "/dashboard-stats",
operation_id = "get_dashboard_stats"
)]
async fn get_dashboard_stats(&self) -> ApiResult<Json<DashboardStats>> {
let stats = DashboardStats::get().await?;
Ok(Json(stats))
}
/// Get the full list of SOCKS5 proxy configurations.
#[oai(method = "get", path = "/list-proxy", operation_id = "list_proxy")]
async fn list_proxy(&self) -> ApiResult<Json<Vec<Proxy>>> {
let proxies = Proxy::list_all()
.await
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
Ok(Json(proxies))
}
/// Delete a specific proxy configuration by ID. Requires root permission.
#[oai(path = "/proxy/:id", method = "delete", operation_id = "remove_proxy")]
async fn remove_proxy(
&self,
/// The name of the OAuth2 configuration to retrieve
id: Path<u64>,
context: ClientContext,
) -> ApiResult<()> {
context.require_root()?;
Ok(Proxy::delete(id.0).await?)
}
/// Retrieve a specific proxy configuration by ID
#[oai(path = "/proxy/:id", method = "get", operation_id = "get_proxy")]
async fn get_proxy(
&self,
/// The name of the OAuth2 configuration to retrieve
id: Path<u64>,
context: ClientContext,
) -> ApiResult<Json<Proxy>> {
context.require_root()?;
Ok(Json(Proxy::get(id.0).await?))
}
/// Create a new proxy configuration. Requires root permission.
#[oai(path = "/proxy", method = "post", operation_id = "create_proxy")]
async fn create_proxy(&self, url: PlainText<String>, context: ClientContext) -> ApiResult<()> {
context.require_root()?;
let entity = Proxy::new(url.0);
Ok(entity.save().await?)
}
/// Update the URL of a specific proxy by ID. Requires root permission.
#[oai(path = "/proxy/:id", method = "post", operation_id = "update_proxy")]
async fn update_proxy(
&self,
id: Path<u64>,
url: PlainText<String>,
context: ClientContext,
) -> ApiResult<()> {
context.require_root()?;
Ok(Proxy::update(id.0, url.0).await?)
}
}
+24
View File
@@ -0,0 +1,24 @@
//
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use rust_embed::RustEmbed;
#[derive(RustEmbed)]
#[folder = "web/dist/"]
pub struct FrontEndAssets;

Some files were not shown because too many files have changed in this diff Show More