refactor(workspace): decompose project into multiple crates

This commit is contained in:
rustmailer
2026-04-23 21:45:34 +08:00
parent 5b884125f7
commit 0b866c81ff
171 changed files with 2203 additions and 2042 deletions
@@ -0,0 +1,94 @@
//
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use crate::common::auth::WrappedContext;
use crate::rest::api::ApiTags;
use crate::rest::ApiResult;
use bichon_core::token::view::AccessTokenResp;
use bichon_core::users::permissions::Permission;
use bichon_core::{token::payload::AccessTokenCreateRequest, token::AccessTokenModel};
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 {
#[oai(
path = "/access-token-list",
method = "get",
operation_id = "list_access_tokens"
)]
async fn list_access_tokens(
&self,
context: WrappedContext,
) -> ApiResult<Json<Vec<AccessTokenResp>>> {
context
.require_permission(None, Permission::TOKEN_MANAGE)
.await?;
Ok(Json(AccessTokenModel::list_all_api_tokens().await?))
}
/// Deletes a specific access token.
#[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: WrappedContext,
) -> ApiResult<()> {
let token = token.0.trim();
let token = AccessTokenModel::get_token(token).await?;
if context.user.id != token.user_id {
context
.require_permission(None, Permission::TOKEN_MANAGE)
.await?;
}
Ok(AccessTokenModel::delete(&token.token).await?)
}
/// Creates a new api token.
#[oai(
path = "/access-token",
method = "post",
operation_id = "create_access_token"
)]
async fn create_access_token(
&self,
context: WrappedContext,
/// The request payload
payload: Json<AccessTokenCreateRequest>,
) -> ApiResult<PlainText<String>> {
let current_user_id = context.user.id;
let target_user_id = payload.0.user_id.unwrap_or(current_user_id);
if target_user_id != current_user_id {
context
.require_permission(None, Permission::USER_MANAGE)
.await?;
}
let token_string = AccessTokenModel::create_api_token(target_user_id, payload.0).await?;
Ok(PlainText(token_string))
}
}
+242
View File
@@ -0,0 +1,242 @@
//
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use crate::common::auth::WrappedContext;
use crate::rest::api::ApiTags;
use crate::rest::ApiResult;
use bichon_core::account::grant::BatchAccountRoleRequest;
use bichon_core::account::migration::AccountModel;
use bichon_core::account::payload::{
filter_accessible_accounts, AccountCreateRequest, AccountUpdateRequest, MinimalAccount,
};
use bichon_core::account::state::DownloadState;
use bichon_core::account::view::AccountResp;
use bichon_core::common::paginated::{paginate_vec, DataPage};
use bichon_core::users::permissions::Permission;
use bichon_core::users::UserModel;
use poem_openapi::param::{Path, Query};
use poem_openapi::payload::Json;
use poem_openapi::OpenApi;
use std::collections::{HashMap, HashSet};
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: WrappedContext,
) -> ApiResult<Json<AccountModel>> {
let account_id = account_id.0;
context
.require_permission(Some(account_id), Permission::ACCOUNT_READ_DETAILS)
.await?;
Ok(Json(AccountModel::async_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: WrappedContext,
) -> ApiResult<()> {
let account_id = account_id.0;
context
.require_permission(Some(account_id), Permission::ACCOUNT_MANAGE)
.await?;
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: WrappedContext,
) -> ApiResult<Json<AccountModel>> {
context
.require_permission(None, Permission::ACCOUNT_CREATE)
.await?;
let account = AccountModel::create_account(context.user.id, payload.0).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: WrappedContext,
) -> ApiResult<()> {
let account_id = account_id.0;
context
.require_permission(Some(account_id), Permission::ACCOUNT_MANAGE)
.await?;
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: WrappedContext,
) -> ApiResult<Json<DataPage<AccountResp>>> {
let is_admin = context.user.is_admin().await;
let sort_desc = desc.0.unwrap_or(true);
let user_map: HashMap<u64, UserModel> = UserModel::list_all()
.await?
.into_iter()
.map(|u| (u.id, u))
.collect();
let page_data: DataPage<AccountModel> = if is_admin {
AccountModel::paginate_list(page.0, page_size.0, desc.0).await?
} else {
let authorized_ids: HashSet<u64> =
context.user.account_access_map.keys().cloned().collect();
if authorized_ids.is_empty() {
return Ok(Json(DataPage {
current_page: page.0,
page_size: page_size.0,
total_items: 0,
items: vec![],
total_pages: Some(0),
}));
}
let mut accounts: Vec<AccountModel> = AccountModel::list_all()
.await?
.into_iter()
.filter(|acct| authorized_ids.contains(&acct.id))
.collect();
accounts.sort_by(|a, b| {
if sort_desc {
b.created_at.cmp(&a.created_at)
} else {
a.created_at.cmp(&b.created_at)
}
});
paginate_vec(&accounts, page.0, page_size.0).map(DataPage::from)?
};
let items = page_data
.items
.into_iter()
.map(|account| AccountResp::from_model(account, &user_map))
.collect();
Ok(Json(DataPage {
current_page: page_data.current_page,
page_size: page_data.page_size,
total_items: page_data.total_items,
total_pages: page_data.total_pages,
items,
}))
}
/// 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: WrappedContext,
) -> ApiResult<Json<DownloadState>> {
let account_id = account_id.0;
AccountModel::check_account_exists(account_id).await?;
context
.require_permission(Some(account_id), Permission::ACCOUNT_READ_DETAILS)
.await?;
let state = DownloadState::get(account_id).await?;
let state = state.unwrap_or(DownloadState::empty(account_id));
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,
only_nosync: Query<Option<bool>>,
context: WrappedContext,
) -> ApiResult<Json<Vec<MinimalAccount>>> {
let is_admin = context.user.is_admin().await;
let only_nosync = only_nosync.0.unwrap_or_default();
let minimal_list = AccountModel::minimal_list(only_nosync).await?;
if is_admin {
return Ok(Json(minimal_list));
}
let authorized_ids: Vec<u64> = context.user.account_access_map.keys().cloned().collect();
let result = filter_accessible_accounts(&minimal_list, &authorized_ids);
Ok(Json(result))
}
#[oai(path = "/accounts/access/assignments", method = "post")]
async fn batch_assign_account_role(
&self,
req: Json<BatchAccountRoleRequest>,
context: WrappedContext,
) -> ApiResult<()> {
req.validate_existence().await?;
req.0.do_assign(&context).await?;
Ok(())
}
}
+195
View File
@@ -0,0 +1,195 @@
//
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use crate::common::auth::WrappedContext;
use crate::rest::api::ApiTags;
use crate::rest::ApiResult;
use bichon_core::common::paginated::DataPage;
use bichon_core::error::code::ErrorCode;
use bichon_core::message::attachment::AttachmentMetadata;
use bichon_core::message::search::search_attachment_impl;
use bichon_core::message::search::AttachmentSearchRequest;
use bichon_core::message::tags::TagCount;
use bichon_core::message::tags::TagsRequest;
use bichon_core::raise_error;
use bichon_core::store::tantivy::attachment::ATTACHMENT_MANAGER;
use bichon_core::store::tantivy::model::AttachmentModel;
use bichon_core::users::permissions::Permission;
use poem_openapi::param::Path;
use poem_openapi::payload::Json;
use poem_openapi::OpenApi;
use std::collections::HashSet;
use tantivy::schema::Facet;
pub struct AttachmentApi;
#[OpenApi(prefix_path = "/api/v1", tag = "ApiTags::Attachment")]
impl AttachmentApi {
/// Searches messages across all mailboxes using various filter criteria.
/// The search filters are provided in the request body.
#[oai(
path = "/search-attachment",
method = "post",
operation_id = "search_attachment"
)]
async fn search_attachment(
&self,
payload: Json<AttachmentSearchRequest>,
context: WrappedContext,
) -> ApiResult<Json<DataPage<AttachmentModel>>> {
let authorized_ids: Option<HashSet<u64>> = if context
.has_permission(None, Permission::DATA_READ_ALL)
.await
{
None
} else {
Some(context.user.account_access_map.keys().cloned().collect())
};
Ok(Json(
search_attachment_impl(authorized_ids, payload.0).await?,
))
}
/// Retrieves the attachment (metadata) of a specific message.
#[oai(
path = "/attachment/:account_id/:attachment_id",
method = "get",
operation_id = "get_attachment"
)]
async fn get_attachment(
&self,
/// The ID of the account.
account_id: Path<u64>,
/// The ID of the attachment.
attachment_id: Path<String>,
context: WrappedContext,
) -> ApiResult<Json<AttachmentModel>> {
let account_id = account_id.0;
context
.require_permission(Some(account_id), Permission::DATA_READ)
.await?;
let attachment_id = attachment_id.0;
let a = ATTACHMENT_MANAGER
.get_attachment_by_id(account_id, &attachment_id)
.await?
.ok_or_else(|| {
raise_error!(
format!(
"Attachment not found: account_id={} envelope_id={}",
account_id, &attachment_id
),
ErrorCode::ResourceNotFound
)
})?;
Ok(Json(a))
}
/// Returns all facets in the index along with their document counts.
#[oai(
path = "/all-attachment-tags",
method = "get",
operation_id = "get_all_attachment_tags"
)]
async fn get_all_attachment_tags(
&self,
context: WrappedContext,
) -> ApiResult<Json<Vec<TagCount>>> {
let authorized_ids: Option<HashSet<u64>> = if context
.has_permission(None, Permission::DATA_READ_ALL)
.await
{
None
} else {
Some(context.user.account_access_map.keys().cloned().collect())
};
Ok(Json(ATTACHMENT_MANAGER.get_all_tags(authorized_ids).await?))
}
/// Adds or removes facet tags for multiple emails across accounts.
#[oai(
path = "/update-attachment-tags",
method = "post",
operation_id = "update_attachment_tags"
)]
async fn update_attachment_tags(
&self,
req: Json<TagsRequest>,
context: WrappedContext,
) -> ApiResult<()> {
let req = req.0;
for tag in &req.tags {
Facet::from_text(tag)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InvalidParameter))?;
}
for account_id in req.updates.keys() {
context
.require_permission(Some(*account_id), Permission::DATA_MANAGE)
.await?;
}
ATTACHMENT_MANAGER.update_attachment_tags(req).await?;
Ok(())
}
/// Retrieves a unique list of all contact email addresses across authorized accounts.
#[oai(
path = "/attachment-senders",
method = "get",
operation_id = "get_attachment_senders"
)]
async fn get_attachment_senders(
&self,
context: WrappedContext,
) -> ApiResult<Json<HashSet<String>>> {
let authorized_ids: Option<HashSet<u64>> = if context
.has_permission(None, Permission::DATA_READ_ALL)
.await
{
None
} else {
Some(context.user.account_access_map.keys().cloned().collect())
};
Ok(Json(
ATTACHMENT_MANAGER.get_all_senders(authorized_ids).await?,
))
}
/// Retrieves unique metadata for all attachments across authorized accounts.
#[oai(
path = "/attachment_metadata",
method = "get",
operation_id = "get_attachment_metadata"
)]
async fn get_attachment_metadata(
&self,
context: WrappedContext,
) -> ApiResult<Json<AttachmentMetadata>> {
let authorized_ids: Option<HashSet<u64>> = if context
.has_permission(None, Permission::DATA_READ_ALL)
.await
{
None
} else {
Some(context.user.account_access_map.keys().cloned().collect())
};
Ok(Json(
ATTACHMENT_MANAGER.collect_attachment_metadata(authorized_ids)?,
))
}
}
+61
View File
@@ -0,0 +1,61 @@
//
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use crate::common::auth::WrappedContext;
use crate::rest::api::ApiTags;
use crate::rest::ApiResult;
use bichon_core::autoconfig::entity::MailServerConfig;
use bichon_core::autoconfig::load::resolve_autoconfig;
use bichon_core::error::code::ErrorCode;
use bichon_core::raise_error;
use bichon_core::users::permissions::Permission;
use poem_openapi::param::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>,
context: WrappedContext,
) -> ApiResult<Json<MailServerConfig>> {
context
.require_permission(None, Permission::ACCOUNT_CREATE)
.await?;
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))
}
}
+52
View File
@@ -0,0 +1,52 @@
//
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use crate::common::auth::WrappedContext;
use crate::rest::api::ApiTags;
use crate::rest::ApiResult;
use bichon_core::import::BatchEmlResult;
use bichon_core::import::{BatchEmlRequest, ImportEmls};
use bichon_core::users::permissions::Permission;
use poem_openapi::payload::Json;
use poem_openapi::OpenApi;
pub struct ImportApi;
#[OpenApi(prefix_path = "/api/v1", tag = "ApiTags::Import")]
impl ImportApi {
/// Batch import one or more EML files into a specified account and mail folder.
///
/// This endpoint accepts a JSON payload containing:
/// - `account_id`: the target account to import emails into
/// - `mail_folder`: the mailbox/folder name
/// - `emls`: a list of base64-encoded .eml files
///
/// Returns a summary of the import result, including total processed, successful, and failed emails.
#[oai(path = "/import", method = "post", operation_id = "do_batch_import")]
async fn do_batch_import(
&self,
/// JSON payload with account info and EML files to import
payload: Json<BatchEmlRequest>,
context: WrappedContext,
) -> ApiResult<Json<BatchEmlResult>> {
context
.require_permission(Some(payload.0.account_id), Permission::DATA_IMPORT_BATCH)
.await?;
Ok(Json(ImportEmls::do_import(payload.0).await?))
}
}
+90
View File
@@ -0,0 +1,90 @@
//
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use crate::common::auth::WrappedContext;
use crate::rest::api::ApiTags;
use crate::rest::ApiResult;
use bichon_core::cache::imap::mailbox::MailBox;
use bichon_core::mailbox::delete::delete_mailbox_impl;
use bichon_core::mailbox::list::get_account_mailboxes;
use bichon_core::users::permissions::Permission;
use poem_openapi::param::{Path, 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: WrappedContext,
) -> ApiResult<Json<Vec<MailBox>>> {
let account_id = account_id.0;
context
.require_permission(Some(account_id), Permission::ACCOUNT_READ_DETAILS)
.await?;
let remote = remote.0.unwrap_or(false);
Ok(Json(get_account_mailboxes(account_id, remote).await?))
}
/// Deletes a mailbox for the specified account.
///
/// Requires `DATA_DELETE` permission on the target account.
///
/// # Parameters
/// - `account_id`: Account identifier.
/// - `mailbox_id`: Mailbox identifier.
///
#[oai(
path = "/delete-mailbox/:account_id/:mailbox_id",
method = "delete",
operation_id = "delete_mailbox"
)]
async fn delete_mailbox(
&self,
/// The unique identifier of the account.
account_id: Path<u64>,
mailbox_id: Path<u64>,
context: WrappedContext,
) -> ApiResult<()> {
let account_id = account_id.0;
let mailbox_id = mailbox_id.0;
context
.require_permission(Some(account_id), Permission::DATA_DELETE)
.await?;
Ok(delete_mailbox_impl(account_id, mailbox_id).await?)
}
}
+386
View File
@@ -0,0 +1,386 @@
//
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use crate::common::auth::WrappedContext;
use crate::rest::api::ApiTags;
use crate::rest::ApiResult;
use bichon_core::account::migration::AccountModel;
use bichon_core::common::paginated::DataPage;
use bichon_core::error::code::ErrorCode;
use bichon_core::message::append::restore_emails;
use bichon_core::message::append::RestoreMessagesRequest;
use bichon_core::message::attachment::retrieve_attachment_content;
use bichon_core::message::attachment::retrieve_nested_attachment_content;
use bichon_core::message::content::retrieve_nested_eml_content;
use bichon_core::message::content::FullNestedMessageContent;
use bichon_core::message::content::{retrieve_email_content, FullMessageContent};
use bichon_core::message::delete::delete_messages_impl;
use bichon_core::message::list::get_thread_messages;
use bichon_core::message::search::{search_messages_impl, EmailSearchRequest};
use bichon_core::message::tags::TagCount;
use bichon_core::message::tags::TagsRequest;
use bichon_core::raise_error;
use bichon_core::store::envelope::Envelope;
use bichon_core::store::storage::get_reader;
use bichon_core::store::tantivy::envelope::ENVELOPE_MANAGER;
use bichon_core::users::permissions::Permission;
use poem::Body;
use poem_openapi::param::{Path, Query};
use poem_openapi::payload::{Attachment, AttachmentType, Json};
use poem_openapi::OpenApi;
use std::collections::HashMap;
use std::collections::HashSet;
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<String>>>,
context: WrappedContext,
) -> ApiResult<()> {
let request = payload.0;
for account_id in request.keys() {
context
.require_permission(Some(*account_id), Permission::DATA_DELETE)
.await?;
}
Ok(delete_messages_impl(request).await?)
}
/// Searches messages across all mailboxes using various filter criteria.
/// The search filters are provided in the request body.
#[oai(
path = "/search-messages",
method = "post",
operation_id = "search_messages"
)]
async fn search_messages(
&self,
payload: Json<EmailSearchRequest>,
context: WrappedContext,
) -> ApiResult<Json<DataPage<Envelope>>> {
let authorized_ids: Option<HashSet<u64>> = if context
.has_permission(None, Permission::DATA_READ_ALL)
.await
{
None
} else {
Some(context.user.account_access_map.keys().cloned().collect())
};
Ok(Json(search_messages_impl(authorized_ids, payload.0).await?))
}
/// Retrieves all messages belonging to a specific thread. Requires `thread_id`, `page`, and `page_size` query parameters.
#[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<String>,
/// The page number for pagination (1-based).
page: Query<u64>,
/// The number of messages per page.
page_size: Query<u64>,
context: WrappedContext,
) -> ApiResult<Json<DataPage<Envelope>>> {
let account_id = account_id.0;
let thread_id = thread_id.0.trim();
context
.require_permission(Some(account_id), Permission::DATA_READ)
.await?;
Ok(Json(
get_thread_messages(account_id, thread_id, page.0, page_size.0).await?,
))
}
/// Fetches the content of a specific email.
#[oai(
path = "/message-content/:account_id/:envelope_id",
method = "get",
operation_id = "fetch_message_content"
)]
async fn fetch_message_content(
&self,
/// The ID of the account.
account_id: Path<u64>,
/// The ID of the message to fetch.
envelope_id: Path<String>,
context: WrappedContext,
) -> ApiResult<Json<FullMessageContent>> {
let account_id = account_id.0;
context
.require_permission(Some(account_id), Permission::DATA_READ)
.await?;
Ok(Json(
retrieve_email_content(account_id, envelope_id.0).await?,
))
}
/// Retrieves the content of an email embedded as an attachment.
#[oai(
path = "/nested-message-content/:account_id/:envelope_id",
method = "get",
operation_id = "fetch_nested_message_content"
)]
async fn fetch_nested_message_content(
&self,
/// The ID of the account.
account_id: Path<u64>,
/// The ID of the message to fetch.
envelope_id: Path<String>,
content_hash: Query<String>,
context: WrappedContext,
) -> ApiResult<Json<FullNestedMessageContent>> {
let account_id = account_id.0;
context
.require_permission(Some(account_id), Permission::DATA_READ)
.await?;
let content_hash = content_hash.0.trim();
Ok(Json(
retrieve_nested_eml_content(account_id, envelope_id.0, content_hash).await?,
))
}
/// Retrieves the envelope (metadata) of a specific message.
#[oai(
path = "/envelope/:account_id/:envelope_id",
method = "get",
operation_id = "get_envelope"
)]
async fn get_envelope(
&self,
/// The ID of the account.
account_id: Path<u64>,
/// The ID of the message.
envelope_id: Path<String>,
context: WrappedContext,
) -> ApiResult<Json<Envelope>> {
let account_id = account_id.0;
context
.require_permission(Some(account_id), Permission::DATA_READ)
.await?;
let envelope_id = envelope_id.0;
let e = ENVELOPE_MANAGER
.get_envelope_by_id(account_id, &envelope_id)
.await?
.ok_or_else(|| {
raise_error!(
format!(
"Envelope not found: account_id={} envelope_id={}",
account_id, &envelope_id
),
ErrorCode::ResourceNotFound
)
})?;
Ok(Json(e.envelope))
}
/// Downloads the raw EML file of a specific email.
#[oai(
path = "/download-message/:account_id/:envelope_id",
method = "get",
operation_id = "download_message"
)]
async fn download_message(
&self,
/// The ID of the account.
account_id: Path<u64>,
/// The ID of the message to download.
envelope_id: Path<String>,
context: WrappedContext,
) -> ApiResult<Attachment<Body>> {
let account_id = account_id.0;
AccountModel::check_account_exists(account_id).await?;
context
.require_permission(Some(account_id), Permission::DATA_RAW_DOWNLOAD)
.await?;
let envelope_id = envelope_id.0;
let reader = get_reader(account_id, envelope_id.clone()).await?;
let body = Body::from_async_read(reader);
let attachment = Attachment::new(body)
.attachment_type(AttachmentType::Attachment)
.filename(format!("{envelope_id}.eml"));
Ok(attachment)
}
/// Restore an email to an account's IMAP server.
#[oai(
path = "/restore-messages/:account_id",
method = "post",
operation_id = "restore_messages"
)]
async fn restore_messages(
&self,
account_id: Path<u64>,
/// Message IDs to restore.
payload: Json<RestoreMessagesRequest>,
context: WrappedContext,
) -> ApiResult<()> {
let account_id = account_id.0;
context
.require_permission(Some(account_id), Permission::DATA_EXPORT_BATCH)
.await?;
Ok(restore_emails(account_id, payload.0.envelope_ids).await?)
}
/// Downloads a specific attachment from an email. Requires `name` query parameter.
#[oai(
path = "/download-attachment/:account_id/:envelope_id",
method = "get",
operation_id = "download_attachment"
)]
async fn download_attachment(
&self,
/// The ID of the account.
account_id: Path<u64>,
/// The ID of the message containing the attachment.
envelope_id: Path<String>,
/// The content_hash of the attachment to download.
content_hash: Query<String>,
context: WrappedContext,
) -> ApiResult<Attachment<Body>> {
let account_id = account_id.0;
let envelope_id = envelope_id.0.trim().to_string();
AccountModel::check_account_exists(account_id).await?;
context
.require_permission(Some(account_id), Permission::DATA_READ)
.await?;
let content_hash = content_hash.0.trim();
let reader = retrieve_attachment_content(account_id, envelope_id, content_hash).await?;
let body = Body::from_async_read(reader);
let attachment = Attachment::new(body)
.attachment_type(AttachmentType::Attachment)
.filename(content_hash);
Ok(attachment)
}
/// Downloads an attachment from within a nested email (EML file).
#[oai(
path = "/download-nested-attachment/:account_id/:envelope_id",
method = "get",
operation_id = "download_nested_attachment"
)]
async fn download_nested_attachment(
&self,
/// The ID of the account.
account_id: Path<u64>,
/// The ID of the message containing the attachment.
envelope_id: Path<String>,
/// The filename of the attachment to download.
content_hash: Query<String>,
nested_content_hash: Query<String>,
context: WrappedContext,
) -> ApiResult<Attachment<Body>> {
let account_id = account_id.0;
let envelope_id = envelope_id.0.trim().to_string();
AccountModel::check_account_exists(account_id).await?;
context
.require_permission(Some(account_id), Permission::DATA_READ)
.await?;
let content_hash = content_hash.0.trim();
let nested_content_hash = nested_content_hash.0.trim();
let reader = retrieve_nested_attachment_content(
account_id,
envelope_id,
content_hash,
nested_content_hash,
)
.await?;
let body = Body::from_async_read(reader);
let attachment = Attachment::new(body)
.attachment_type(AttachmentType::Attachment)
.filename(nested_content_hash);
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, context: WrappedContext) -> ApiResult<Json<Vec<TagCount>>> {
let authorized_ids: Option<HashSet<u64>> = if context
.has_permission(None, Permission::DATA_READ_ALL)
.await
{
None
} else {
Some(context.user.account_access_map.keys().cloned().collect())
};
Ok(Json(ENVELOPE_MANAGER.get_all_tags(authorized_ids).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<TagsRequest>,
context: WrappedContext,
) -> ApiResult<()> {
let req = req.0;
for tag in &req.tags {
Facet::from_text(tag)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InvalidParameter))?;
}
for account_id in req.updates.keys() {
context
.require_permission(Some(*account_id), Permission::DATA_MANAGE)
.await?;
}
ENVELOPE_MANAGER.update_envelope_tags(req).await?;
Ok(())
}
/// Retrieves a unique list of all contact email addresses across authorized accounts.
#[oai(
path = "/all-contacts",
method = "get",
operation_id = "get_all_contacts"
)]
async fn get_all_contacts(&self, context: WrappedContext) -> ApiResult<Json<HashSet<String>>> {
let authorized_ids: Option<HashSet<u64>> = if context
.has_permission(None, Permission::DATA_READ_ALL)
.await
{
None
} else {
Some(context.user.account_access_map.keys().cloned().collect())
};
Ok(Json(
ENVELOPE_MANAGER.get_all_contacts(authorized_ids).await?,
))
}
}
+85
View File
@@ -0,0 +1,85 @@
//
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use access_token::AccessTokenApi;
use account::AccountApi;
use auto_config::AutoConfigApi;
use bichon_core::bichon_version;
use mailbox::MailBoxApi;
use message::MessageApi;
use oauth2::OAuth2Api;
use poem_openapi::{OpenApiService, Tags};
use crate::rest::api::{attachment::AttachmentApi, import::ImportApi, users::UsersApi};
use system::SystemApi;
pub mod access_token;
pub mod account;
pub mod attachment;
pub mod auto_config;
pub mod import;
pub mod mailbox;
pub mod message;
pub mod oauth2;
pub mod system;
pub mod users;
#[derive(Tags)]
pub enum ApiTags {
AccessToken,
Attachment,
AutoConfig,
Account,
Mailbox,
OAuth2,
Message,
System,
Import,
Users,
}
type RustMailOpenApi = (
AccessTokenApi,
AttachmentApi,
AutoConfigApi,
AccountApi,
SystemApi,
MailBoxApi,
OAuth2Api,
MessageApi,
ImportApi,
UsersApi,
);
pub fn create_openapi_service() -> OpenApiService<RustMailOpenApi, ()> {
OpenApiService::new(
(
AccessTokenApi,
AttachmentApi,
AutoConfigApi,
AccountApi,
SystemApi,
MailBoxApi,
OAuth2Api,
MessageApi,
ImportApi,
UsersApi,
),
"BichonApi",
bichon_version!(),
)
}
+250
View File
@@ -0,0 +1,250 @@
//
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use bichon_core::account::migration::AccountModel;
use bichon_core::common::paginated::DataPage;
use bichon_core::error::code::ErrorCode;
use crate::common::auth::WrappedContext;
use crate::rest::api::ApiTags;
use crate::rest::ApiResult;
use bichon_core::oauth2::entity::{OAuth2, OAuth2CreateRequest, OAuth2UpdateRequest};
use bichon_core::oauth2::flow::{AuthorizeUrlRequest, OAuth2Flow};
use bichon_core::oauth2::token::{ExternalOAuth2Request, OAuth2AccessToken};
use bichon_core::raise_error;
use bichon_core::users::permissions::Permission;
use poem_openapi::param::{Path, 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 id.
///
/// Requires root privileges.
/// This endpoint fetches the OAuth2 configuration identified by the given id.
#[oai(
path = "/oauth2/:id",
method = "get",
operation_id = "get_oauth2_config"
)]
async fn get_oauth2_config(
&self,
/// The id of the OAuth2 configuration to retrieve
id: Path<u64>,
context: WrappedContext,
) -> ApiResult<Json<OAuth2>> {
let id = id.0;
let mut oauth2 = OAuth2::get(id).await?.ok_or_else(|| {
raise_error!(
format!("OAuth2 configuration id='{id}' not found"),
ErrorCode::ResourceNotFound
)
})?;
if context.has_permission(None, Permission::ROOT).await {
return Ok(Json(oauth2));
}
oauth2.scrub_sensitive_fields();
Ok(Json(oauth2))
}
/// 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: WrappedContext,
) -> ApiResult<()> {
context.require_permission(None, Permission::ROOT).await?;
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: WrappedContext,
) -> ApiResult<()> {
context.require_permission(None, Permission::ROOT).await?;
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: WrappedContext,
) -> ApiResult<()> {
context.require_permission(None, Permission::ROOT).await?;
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: WrappedContext,
) -> ApiResult<Json<DataPage<OAuth2>>> {
let mut list = OAuth2::paginate_list(page.0, page_size.0, desc.0).await?;
if context.has_permission(None, Permission::ROOT).await {
return Ok(Json(list));
}
//Non-root users can only view masked data.
for item in &mut list.items {
item.scrub_sensitive_fields();
}
Ok(Json(list))
}
/// 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: WrappedContext,
) -> ApiResult<PlainText<String>> {
let request = request.0;
context
.require_any_permission(vec![
(None, Permission::ACCOUNT_CREATE),
(Some(request.account_id), Permission::ACCOUNT_MANAGE),
])
.await?;
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: WrappedContext,
) -> ApiResult<Json<OAuth2AccessToken>> {
let account = account_id.0;
context
.require_permission(Some(account), Permission::ACCOUNT_MANAGE)
.await?;
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, Bichon will store it directly.
/// - In this mode, Bichon **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 Bichon.
/// - Since the OAuth2 configuration (including client_id and client_secret)
/// is already stored in Bichon, 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 Bichon.
#[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: WrappedContext,
) -> ApiResult<()> {
let account_id = account_id.0;
AccountModel::check_account_exists(account_id).await?;
// Check account access permissions
context
.require_permission(Some(account_id), Permission::ACCOUNT_MANAGE)
.await?;
OAuth2AccessToken::upsert_external_oauth_token(account_id, request.0).await?;
Ok(())
}
}
+143
View File
@@ -0,0 +1,143 @@
//
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use crate::common::auth::WrappedContext;
use crate::rest::api::ApiTags;
use crate::rest::ApiResult;
use bichon_core::dashboard::DashboardStats;
use bichon_core::error::code::ErrorCode;
use bichon_core::raise_error;
use bichon_core::settings::cli::SETTINGS;
use bichon_core::settings::proxy::Proxy;
use bichon_core::settings::SystemConfigurations;
use bichon_core::users::permissions::Permission;
use bichon_core::version::{fetch_notifications, Notifications};
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 Bichon 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,
context: WrappedContext,
) -> ApiResult<Json<DashboardStats>> {
let stats = DashboardStats::get(context.0).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, _context: WrappedContext) -> ApiResult<Json<Vec<Proxy>>> {
//The proxy list is visible to all users.
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 ID of the proxy configuration to delete.
id: Path<u64>,
context: WrappedContext,
) -> ApiResult<()> {
context.require_permission(None, Permission::ROOT).await?;
Ok(Proxy::delete(id.0).await?)
}
/// Retrieve a specific proxy configuration by ID. Requires root permission.
#[oai(path = "/proxy/:id", method = "get", operation_id = "get_proxy")]
async fn get_proxy(
&self,
/// The ID of the proxy configuration to retrieve.
id: Path<u64>,
context: WrappedContext,
) -> ApiResult<Json<Proxy>> {
context.require_permission(None, Permission::ROOT).await?;
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: WrappedContext) -> ApiResult<()> {
context.require_permission(None, Permission::ROOT).await?;
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: WrappedContext,
) -> ApiResult<()> {
context.require_permission(None, Permission::ROOT).await?;
Ok(Proxy::update(id.0, url.0).await?)
}
/// Get system configurations.
///
/// Returns a read-only snapshot of the server configuration
/// resolved at startup. Sensitive values are not exposed.
#[oai(
method = "get",
path = "/system-configurations",
operation_id = "get_system_configurations"
)]
async fn get_system_configurations(
&self,
context: WrappedContext,
) -> ApiResult<Json<SystemConfigurations>> {
context.require_permission(None, Permission::ROOT).await?;
let config: SystemConfigurations = SystemConfigurations::from(&*SETTINGS);
Ok(Json(config))
}
}
+231
View File
@@ -0,0 +1,231 @@
//
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use std::collections::BTreeMap;
use crate::common::auth::WrappedContext;
use crate::rest::api::ApiTags;
use crate::rest::ApiResult;
use bichon_core::token::AccessTokenModel;
use bichon_core::users::minimal::MinimalUser;
use bichon_core::users::payload::{
RoleCreateRequest, RoleUpdateRequest, UserCreateRequest, UserUpdateRequest,
};
use bichon_core::users::permissions::Permission;
use bichon_core::users::role::{RoleType, UserRole};
use bichon_core::users::view::UserView;
use bichon_core::users::UserModel;
use poem::web::Path;
use poem_openapi::payload::Json;
use poem_openapi::OpenApi;
pub struct UsersApi;
#[OpenApi(prefix_path = "/api/v1", tag = "ApiTags::Users")]
impl UsersApi {
#[oai(path = "/list-roles", method = "get", operation_id = "list_roles")]
async fn list_roles(&self, context: WrappedContext) -> ApiResult<Json<Vec<UserRole>>> {
context
.require_permission(None, Permission::USER_MANAGE)
.await?;
Ok(Json(UserRole::list_all().await?))
}
#[oai(path = "/roles/:id", method = "delete", operation_id = "remove_role")]
async fn remove_role(
&self,
/// The Role ID to delete
id: Path<u64>,
context: WrappedContext,
) -> ApiResult<()> {
let id = id.0;
context
.require_permission(None, Permission::USER_MANAGE)
.await?;
Ok(UserRole::delete(id).await?)
}
/// Create a new account
#[oai(path = "/roles", method = "post", operation_id = "create_role")]
async fn create_role(
&self,
/// Role creation request payload
payload: Json<RoleCreateRequest>,
context: WrappedContext,
) -> ApiResult<Json<UserRole>> {
context
.require_permission(None, Permission::USER_MANAGE)
.await?;
let role = UserRole::create(payload.0).await?;
Ok(Json(role))
}
/// Update an existing account
#[oai(path = "/roles/:id", method = "post", operation_id = "update_role")]
async fn update_role(
&self,
/// The Role ID to update
id: Path<u64>,
/// Role update request payload
payload: Json<RoleUpdateRequest>,
context: WrappedContext,
) -> ApiResult<()> {
let id = id.0;
context
.require_permission(None, Permission::USER_MANAGE)
.await?;
Ok(UserRole::update(id, payload.0).await?)
}
#[oai(path = "/list-users", method = "get", operation_id = "list_users")]
async fn list_users(&self, context: WrappedContext) -> ApiResult<Json<Vec<UserView>>> {
context
.require_permission(None, Permission::USER_MANAGE)
.await?;
let roles = UserRole::list_all().await?;
let role_lookup: BTreeMap<u64, UserRole> = roles.into_iter().map(|r| (r.id, r)).collect();
let users = UserModel::list_all().await?;
let users = users.into_iter().map(|u| u.to_view(&role_lookup)).collect();
Ok(Json(users))
}
#[oai(
path = "/user-tokens/:id",
method = "get",
operation_id = "get_user_tokens"
)]
async fn get_user_tokens(
&self,
id: Path<u64>,
context: WrappedContext,
) -> ApiResult<Json<Vec<AccessTokenModel>>> {
let target_user_id = id.0;
let tokens = AccessTokenModel::get_user_api_tokens(target_user_id).await?;
if context.user.id == target_user_id {
return Ok(Json(tokens));
}
context
.require_permission(None, Permission::USER_MANAGE)
.await?;
Ok(Json(tokens))
}
#[oai(path = "/users/:id", method = "delete", operation_id = "remove_user")]
async fn remove_user(
&self,
/// The User ID to delete
id: Path<u64>,
context: WrappedContext,
) -> ApiResult<()> {
let id = id.0;
context
.require_permission(None, Permission::USER_MANAGE)
.await?;
Ok(UserModel::remove(id).await?)
}
#[oai(path = "/users", method = "post", operation_id = "create_user")]
async fn create_user(
&self,
payload: Json<UserCreateRequest>,
context: WrappedContext,
) -> ApiResult<Json<UserView>> {
context
.require_permission(None, Permission::USER_MANAGE)
.await?;
let user = UserModel::create(payload.0).await?;
let roles = UserRole::list_all().await?;
let role_lookup: BTreeMap<u64, UserRole> = roles.into_iter().map(|r| (r.id, r)).collect();
Ok(Json(user.to_view(&role_lookup)))
}
#[oai(path = "/users/:id", method = "post", operation_id = "update_user")]
async fn update_user(
&self,
id: Path<u64>,
payload: Json<UserUpdateRequest>,
context: WrappedContext,
) -> ApiResult<()> {
let target_id = id.0;
let current_user_id = context.user.id;
if current_user_id != target_id {
context
.require_permission(None, Permission::USER_MANAGE)
.await?;
}
let mut update_data = payload.0;
if current_user_id == target_id
&& !context.has_permission(None, Permission::USER_MANAGE).await
{
update_data.global_roles = None;
update_data.account_access_map = None;
update_data.acl = None;
}
Ok(UserModel::update(target_id, update_data).await?)
}
#[oai(
path = "/current-user",
method = "get",
operation_id = "get_current_user"
)]
async fn get_current_user(&self, context: WrappedContext) -> ApiResult<Json<UserView>> {
let roles = UserRole::list_all().await?;
let role_lookup: BTreeMap<u64, UserRole> = roles.into_iter().map(|r| (r.id, r)).collect();
Ok(Json(context.0.user.to_view(&role_lookup)))
}
#[oai(
path = "/minimal-user-list",
method = "get",
operation_id = "get_minimal_user_list"
)]
async fn get_minimal_user_list(
&self,
context: WrappedContext,
) -> ApiResult<Json<Vec<MinimalUser>>> {
let is_admin = context.user.is_admin().await;
let minimal_list = MinimalUser::list_all().await?;
if is_admin {
return Ok(Json(minimal_list));
}
context
.require_permission(None, Permission::USER_VIEW)
.await?;
Ok(Json(minimal_list))
}
#[oai(
path = "/list-account-roles",
method = "get",
operation_id = "list_account_roles"
)]
async fn list_account_roles(&self, context: WrappedContext) -> ApiResult<Json<Vec<UserRole>>> {
context
.require_permission(None, Permission::USER_VIEW)
.await?;
let all = UserRole::list_all().await?;
Ok(Json(
all.into_iter()
.filter(|r| matches!(r.role_type, RoleType::Account))
.collect(),
))
}
}
+24
View File
@@ -0,0 +1,24 @@
//
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use rust_embed::RustEmbed;
#[derive(RustEmbed)]
#[folder = "../../web/dist/"]
pub struct FrontEndAssets;
+182
View File
@@ -0,0 +1,182 @@
//
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use crate::common::error::ErrorCapture;
use crate::common::log::Tracing;
use crate::common::tls::rustls_config;
use crate::error::handler::error_handler;
use crate::rest::public::login::login;
use crate::rest::public::status::get_status;
use bichon_core::common::signal::SIGNAL_MANAGER;
use bichon_core::error::code::ErrorCode;
use bichon_core::error::BichonResult;
use bichon_core::settings::cli::SETTINGS;
use super::error::ApiErrorResponse;
use crate::common::auth::ApiGuard;
use crate::common::timeout::{Timeout, TIMEOUT_HEADER};
use api::create_openapi_service;
use assets::FrontEndAssets;
use bichon_core::raise_error;
use http::{HeaderValue, Method};
use poem::endpoint::EmbeddedFilesEndpoint;
use poem::listener::{Listener, TcpListener};
use poem::middleware::{CatchPanic, Compression, SetHeader};
use poem::{get, handler, post, IntoResponse};
use poem::{middleware::Cors, EndpointExt, Route, Server};
use public::oauth2::oauth2_callback;
use std::collections::HashSet;
use std::time::Duration;
pub mod api;
pub mod assets;
pub mod public;
pub type ApiResult<T, E = ApiErrorResponse> = std::result::Result<T, E>;
pub async fn start_http_server() -> BichonResult<()> {
let listener = TcpListener::bind((
SETTINGS.bichon_bind_ip.clone().unwrap_or("0.0.0.0".into()),
SETTINGS.bichon_http_port as u16,
));
let listener = if SETTINGS.bichon_enable_rest_https {
listener.rustls(rustls_config()?).boxed()
} else {
listener.boxed()
};
let api_service = create_openapi_service()
.summary("A lightweight, high-performance Rust email archiver with WebUI");
let swagger = api_service.swagger_ui();
let redoc = api_service.redoc();
let scalar = api_service.scalar();
let spec_json = api_service.spec_endpoint();
let spec_yaml = api_service.spec_endpoint_yaml();
let openapi_explorer = api_service.openapi_explorer();
let open_api_route = Route::new()
.nest_no_strip("/api/v1", api_service)
.with(ApiGuard)
.with(ErrorCapture)
.with(Timeout)
.with(Tracing);
let cors_origins: Option<HashSet<String>> = SETTINGS.bichon_cors_origins.clone();
let cors_origins: Vec<String> = cors_origins.unwrap_or_default().into_iter().collect();
let cors = Cors::new()
.allow_origins_fn(move |origin| {
tracing::debug!("CORS: Incoming Origin = {:?}", origin);
tracing::debug!("CORS: Configured origins = {:?}", cors_origins);
if cors_origins.is_empty() {
tracing::debug!("CORS: No origins configured, allowing all");
return true;
}
cors_origins.iter().any(|o| o == origin)
})
//.allow_origins(cors_origins)
.allow_credentials(true)
.allow_methods(&[
Method::GET,
Method::POST,
Method::PUT,
Method::DELETE,
Method::OPTIONS,
Method::HEAD,
Method::PATCH,
])
.allow_headers(vec!["Content-Type", "Authorization", TIMEOUT_HEADER])
.expose_headers(vec!["Accept"])
.max_age(SETTINGS.bichon_cors_max_age);
let cache_static = || {
SetHeader::new().overriding(
http::header::CACHE_CONTROL,
HeaderValue::from_static("max-age=86400"),
)
};
let app_logic = Route::new()
.nest("/api-docs/swagger", swagger)
.nest("/api-docs/redoc", redoc)
.nest("/api-docs/explorer", openapi_explorer)
.nest("/api-docs/scalar", scalar)
.nest("/api-docs/spec.json", spec_json)
.nest("/api-docs/spec.yaml", spec_yaml)
.nest("/oauth2/callback", get(oauth2_callback))
.nest("/api/status", get(get_status))
.nest("/api/login", post(login))
.nest_no_strip("/api/v1", open_api_route)
.nest_no_strip(
"/assets",
EmbeddedFilesEndpoint::<FrontEndAssets>::new().with(cache_static()),
)
.at("/*", serve_index_with_base);
let route = Route::new()
.nest(&SETTINGS.bichon_base_url, app_logic)
.with(cors)
.with_if(SETTINGS.bichon_http_compression_enabled, Compression::new())
.with(CatchPanic::new());
let mut rx = SIGNAL_MANAGER.subscribe();
let shutdown_fut = async move {
let _ = rx.recv().await;
};
let server = Server::new(listener)
.name("Bichon Service")
.idle_timeout(Duration::from_secs(60))
.run_with_graceful_shutdown(
route.catch_all_error(error_handler),
shutdown_fut,
Some(Duration::from_secs(5)),
);
println!(
"Bichon Service is now running on port {}.",
SETTINGS.bichon_http_port
);
server
.await
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))
}
#[handler]
async fn serve_index_with_base() -> impl IntoResponse {
let mut html =
String::from_utf8_lossy(&FrontEndAssets::get("index.html").unwrap().data).to_string();
let raw_base = &SETTINGS.bichon_base_url;
let base_href = if raw_base.ends_with('/') {
raw_base.clone()
} else {
format!("{}/", raw_base)
};
let inject_content = format!(
r#"<base href="{}"><script>window.__BICHON_BASE__ = '{}';</script>"#,
base_href, raw_base
);
html = html.replace("<head>", &format!("<head>{}", inject_content));
poem::Response::builder()
.content_type("text/html; charset=utf-8")
.body(html)
}
+57
View File
@@ -0,0 +1,57 @@
//
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use bichon_core::users::UserModel;
use poem::{handler, web::Json, IntoResponse, Response};
use serde::Deserialize;
use tracing::error;
#[derive(Deserialize)]
pub struct LoginPayload {
pub username: String,
pub password: String,
}
/// Login endpoint
///
/// Accepts a plain text password and returns the `root_token`
/// on successful authentication.
#[handler]
pub async fn login(payload: Json<LoginPayload>) -> Response {
let payload = payload.0;
match UserModel::authenticate_user(payload.username, payload.password).await {
Ok(result) => match serde_json::to_string(&result) {
Ok(json_string) => Response::builder()
.status(http::StatusCode::OK)
.content_type("application/json")
.body(json_string)
.into_response(),
Err(_) => Response::builder()
.status(http::StatusCode::INTERNAL_SERVER_ERROR)
.body("Internal server error during response serialization.")
.into_response(),
},
Err(e) => {
error!("Authentication failed with system error: {:?}", e);
Response::builder()
.status(http::StatusCode::INTERNAL_SERVER_ERROR)
.body("Authentication system failed.".to_string())
.into_response()
}
}
}
+22
View File
@@ -0,0 +1,22 @@
//
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
pub mod login;
pub mod oauth2;
pub mod status;
+94
View File
@@ -0,0 +1,94 @@
//
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use bichon_core::oauth2::{flow::OAuth2Flow, pending::OAuth2PendingEntity};
use poem::{
handler,
web::{Query, Redirect},
IntoResponse, Result,
};
use serde::{Deserialize, Serialize};
use tracing::error;
#[derive(Serialize, Deserialize, Debug)]
pub struct OAuth2CallbackParams {
state: Option<String>,
code: Option<String>,
}
#[handler]
pub async fn oauth2_callback(
Query(params): Query<OAuth2CallbackParams>,
) -> Result<impl IntoResponse> {
let (state, code) = match (&params.state, &params.code) {
(Some(state), Some(code)) => (state, code),
(None, _) => {
let message =
"The state parameter is missing. Please initiate the OAuth2 process again.";
return Ok(Redirect::temporary(format!(
"/oauth2-result?error=missing_state&message={}",
urlencoding::encode(message)
))
.into_response());
}
(_, None) => {
let message = "The authorization code is missing. Please try the OAuth2 login again.";
return Ok(Redirect::temporary(format!(
"/oauth2-result?error=missing_code&message={}",
urlencoding::encode(message)
))
.into_response());
}
};
let pending = match OAuth2PendingEntity::get(state).await {
Ok(Some(pending)) => pending,
_ => {
let message =
"The provided state is invalid or expired. Please start the OAuth2 process again.";
return Ok(Redirect::temporary(format!(
"/oauth2-result?error=invalid_state&message={}",
urlencoding::encode(message)
))
.into_response());
}
};
let flow = OAuth2Flow::new(pending.oauth2_id);
if let Err(e) = flow
.fetch_save_access_token(pending.account_id, &pending.code_verifier, code)
.await
{
error!("Failed to save access token: {:#?}", e);
let message = format!(
"Failed to retrieve or save the access token. Error details: {:#?}",
e
);
return Ok(Redirect::temporary(format!(
"/oauth2-result?error=token_fetch_failed&message={}",
urlencoding::encode(&message)
))
.into_response());
}
if let Err(e) = OAuth2PendingEntity::delete(state).await {
error!("Failed to delete pending OAuth2 entity: {}", e);
}
Ok(Redirect::temporary("/oauth2-result?success=true").into_response())
}
+27
View File
@@ -0,0 +1,27 @@
//
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use poem::{handler, web::Json, IntoResponse};
use crate::common::status::BichonStatus;
#[handler]
pub async fn get_status() -> impl IntoResponse {
Json(BichonStatus::get())
}