mirror of
https://github.com/rustmailer/bichon.git
synced 2026-08-03 07:48:34 +02:00
initial commit
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
/target
|
||||
.vscode
|
||||
.idea
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
+114
@@ -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"
|
||||
@@ -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
@@ -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"
|
||||
]
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
cargo build --release
|
||||
@@ -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
@@ -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"
|
||||
@@ -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"]
|
||||
@@ -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"
|
||||
Vendored
+44
@@ -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
@@ -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(())
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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>,
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
Vendored
+207
@@ -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),
|
||||
}
|
||||
}
|
||||
}
|
||||
Vendored
+66
@@ -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()
|
||||
}
|
||||
Vendored
+379
@@ -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(())
|
||||
}
|
||||
Vendored
+113
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
}
|
||||
Vendored
+117
@@ -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(())
|
||||
}
|
||||
}
|
||||
Vendored
+33
@@ -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),
|
||||
))
|
||||
});
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
))
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(()))
|
||||
}
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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(¤t_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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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 (10000–10999)
|
||||
InvalidParameter = 10000,
|
||||
MissingConfiguration = 10020,
|
||||
Incompatible = 10030,
|
||||
PayloadTooLarge = 10070,
|
||||
RequestTimeout = 10080,
|
||||
MethodNotAllowed = 10090,
|
||||
|
||||
// Authentication and authorization errors (20000–20999)
|
||||
PermissionDenied = 20000,
|
||||
AccountDisabled = 20010,
|
||||
OAuth2ItemDisabled = 20050,
|
||||
MissingRefreshToken = 20060,
|
||||
|
||||
// Resource errors (30000–30999)
|
||||
ResourceNotFound = 30000,
|
||||
TooManyRequest = 30020,
|
||||
|
||||
// Network connection errors (40000–40999)
|
||||
NetworkError = 40000,
|
||||
ConnectionTimeout = 40010,
|
||||
ConnectionPoolTimeout = 40020,
|
||||
HttpResponseError = 40030,
|
||||
|
||||
// Mail service errors (50000–50999)
|
||||
ImapCommandFailed = 50000,
|
||||
ImapAuthenticationFailed = 50010,
|
||||
ImapUnexpectedResult = 50020,
|
||||
AutoconfigFetchFailed = 50060,
|
||||
// Internal system errors (70000–70999)
|
||||
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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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>),
|
||||
}
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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);
|
||||
// }
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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),
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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()));
|
||||
}
|
||||
}
|
||||
@@ -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?)
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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?))
|
||||
}
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
@@ -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!(),
|
||||
)
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
@@ -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?)
|
||||
}
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user