mirror of
https://github.com/rustmailer/bichon.git
synced 2026-08-03 07:48:34 +02:00
refactor(workspace): decompose project into multiple crates
This commit is contained in:
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
@@ -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)?,
|
||||
))
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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?))
|
||||
}
|
||||
}
|
||||
@@ -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?)
|
||||
}
|
||||
}
|
||||
@@ -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?,
|
||||
))
|
||||
}
|
||||
}
|
||||
@@ -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!(),
|
||||
)
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
))
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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 (¶ms.state, ¶ms.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())
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
Reference in New Issue
Block a user