initial commit

This commit is contained in:
rustmailer
2025-11-19 02:14:37 +08:00
commit 1a8f95117e
355 changed files with 54089 additions and 0 deletions
+156
View File
@@ -0,0 +1,156 @@
//
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use crate::modules::common::auth::ClientContext;
use crate::modules::rest::api::ApiTags;
use crate::modules::rest::ApiResult;
use crate::modules::token::payload::AccessTokenUpdateRequest;
use crate::modules::token::root::set_root_password;
use crate::modules::{
token::payload::AccessTokenCreateRequest,
token::{root::reset_root_token, AccessToken},
};
use poem_openapi::payload::PlainText;
use poem_openapi::{param::Path, payload::Json, OpenApi};
pub struct AccessTokenApi;
#[OpenApi(prefix_path = "/api/v1", tag = "ApiTags::AccessToken")]
impl AccessTokenApi {
/// Lists all access tokens in the system.
///
/// Requires root privileges.
#[oai(
path = "/access-token-list",
method = "get",
operation_id = "list_access_tokens"
)]
async fn list_access_tokens(
&self,
context: ClientContext,
) -> ApiResult<Json<Vec<AccessToken>>> {
context.require_root()?;
Ok(Json(AccessToken::list_all().await?))
}
/// Lists access tokens for a specific account.
///
/// Requires root privileges.
#[oai(
path = "/access-token-list/:account_id",
method = "get",
operation_id = "list_account_access_tokens"
)]
async fn list_account_access_tokens(
&self,
/// The ID of the account whose tokens are to be retrieved.
account_id: Path<u64>,
context: ClientContext,
) -> ApiResult<Json<Vec<AccessToken>>> {
context.require_root()?;
Ok(Json(AccessToken::list_account_tokens(account_id.0).await?))
}
/// Deletes a specific access token.
///
/// Requires root privileges.
#[oai(
path = "/access-token/:token",
method = "delete",
operation_id = "remove_access_token"
)]
async fn remove_access_token(
&self,
/// The access token to be deleted
token: Path<String>,
context: ClientContext,
) -> ApiResult<()> {
context.require_root()?;
Ok(AccessToken::delete(token.0.trim()).await?)
}
/// Creates a new access token.
///
/// Requires root privileges.
#[oai(
path = "/access-token",
method = "post",
operation_id = "create_access_token"
)]
async fn create_access_token(
&self,
context: ClientContext,
/// The request payload
payload: Json<AccessTokenCreateRequest>,
) -> ApiResult<PlainText<String>> {
context.require_root()?;
Ok(PlainText(AccessToken::create(payload.0).await?))
}
/// Updates an existing access token.
///
/// Requires root privileges.
#[oai(
path = "/access-token/:token",
method = "post",
operation_id = "update_access_token"
)]
async fn update_access_token(
&self,
context: ClientContext,
/// The access token to be updated.
token: Path<String>,
/// The request payload.
payload: Json<AccessTokenUpdateRequest>,
) -> ApiResult<()> {
context.require_root()?;
Ok(AccessToken::update(token.0.trim(), payload.0).await?)
}
/// Regenerates the root access token.
///
/// Requires root privileges.
#[oai(
path = "/reset-root-token",
method = "post",
operation_id = "regenerate_root_token"
)]
async fn regenerate_root_token(&self, context: ClientContext) -> ApiResult<PlainText<String>> {
context.require_root()?;
Ok(PlainText(reset_root_token().await?))
}
/// Reset the Root user's password.
///
/// Only callable by an already authenticated Root user.
/// This endpoint updates the Root password to `password_str`
/// and regenerates the `root_token`, invalidating any previous token.
#[oai(
path = "/reset-root-password",
method = "post",
operation_id = "reset_root_password"
)]
async fn reset_root_password(
&self,
password_str: PlainText<String>,
context: ClientContext,
) -> ApiResult<()> {
context.require_root()?;
Ok(set_root_password(password_str.0.trim()).await?)
}
}
+207
View File
@@ -0,0 +1,207 @@
//
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use std::collections::BTreeSet;
use crate::modules::account::payload::{
filter_accessible_accounts, AccountCreateRequest, AccountUpdateRequest, MinimalAccount,
};
use crate::modules::account::state::AccountRunningState;
use crate::modules::account::migration::AccountModel;
use crate::modules::common::auth::ClientContext;
use crate::modules::common::paginated::paginate_vec;
use crate::modules::error::code::ErrorCode;
use crate::modules::rest::api::ApiTags;
use crate::modules::rest::response::DataPage;
use crate::modules::rest::ApiResult;
use crate::modules::token::{AccessToken, AccountInfo};
use crate::raise_error;
use poem::web::Path;
use poem_openapi::param::Query;
use poem_openapi::payload::Json;
use poem_openapi::OpenApi;
pub struct AccountApi;
#[OpenApi(prefix_path = "/api/v1", tag = "ApiTags::Account")]
impl AccountApi {
/// Get account details by account ID
#[oai(
path = "/account/:account_id",
method = "get",
operation_id = "get_account"
)]
async fn get_account(
&self,
/// The account ID to retrieve
account_id: Path<u64>,
context: ClientContext,
) -> ApiResult<Json<AccountModel>> {
let account_id = account_id.0;
context.require_account_access(account_id)?;
Ok(Json(AccountModel::get(account_id).await?))
}
/// Delete an account by ID - WARNING: This permanently removes the account and all associated resources
#[oai(
path = "/account/:account_id",
method = "delete",
operation_id = "remove_account"
)]
async fn remove_account(
&self,
/// The account ID to delete
account_id: Path<u64>,
context: ClientContext,
) -> ApiResult<()> {
let account_id = account_id.0;
context.require_account_access(account_id)?;
Ok(AccountModel::delete(account_id).await?)
}
/// Create a new account
#[oai(path = "/account", method = "post", operation_id = "create_account")]
async fn create_account(
&self,
/// Account creation request payload
payload: Json<AccountCreateRequest>,
context: ClientContext,
) -> ApiResult<Json<AccountModel>> {
let account = AccountModel::create_account(payload.0).await?;
if let Some(access_token) = &context.access_token {
let account_info = AccountInfo {
id: account.id,
email: account.email.clone(),
};
AccessToken::grant_account_access(&access_token.token, account_info).await?;
}
Ok(Json(account))
}
/// Update an existing account
#[oai(
path = "/account/:account_id",
method = "post",
operation_id = "update_account"
)]
async fn update_account(
&self,
/// The account ID to update
account_id: Path<u64>,
/// Account update request payload
payload: Json<AccountUpdateRequest>,
context: ClientContext,
) -> ApiResult<()> {
let account_id = account_id.0;
context.require_account_access(account_id)?;
Ok(AccountModel::update(account_id, payload.0, true).await?)
}
/// List accounts with optional pagination parameters
#[oai(
path = "/accounts",
method = "get",
operation_id = "list_accounts"
)]
async fn list_accounts(
&self,
/// Optional. The page number to retrieve (starting from 1).
page: Query<Option<u64>>,
/// Optional. The number of items per page.
page_size: Query<Option<u64>>,
/// Optional. Whether to sort the list in descending order.
desc: Query<Option<bool>>,
context: ClientContext,
) -> ApiResult<Json<DataPage<AccountModel>>> {
let accessible_accounts = context.accessible_accounts()?;
if accessible_accounts.is_none() {
return Ok(Json(
AccountModel::paginate_list(page.0, page_size.0, desc.0).await?,
));
}
let all_accounts = AccountModel::list_all().await?;
let allowed_ids: BTreeSet<u64> =
accessible_accounts.unwrap().iter().map(|a| a.id).collect();
let mut filtered_accounts: Vec<AccountModel> = all_accounts
.into_iter()
.filter(|acct| allowed_ids.contains(&acct.id))
.collect();
let sort_desc = desc.0.unwrap_or(true);
filtered_accounts.sort_by(|a, b| {
if sort_desc {
b.created_at.cmp(&a.created_at)
} else {
a.created_at.cmp(&b.created_at)
}
});
let page_data =
paginate_vec(&filtered_accounts, page.0, page_size.0).map(DataPage::from)?;
Ok(Json(page_data))
}
/// Get the running state of an account
#[oai(
path = "/account-state/:account_id",
method = "get",
operation_id = "account_state"
)]
async fn account_state(
&self,
/// The account ID to check state for
account_id: Path<u64>,
context: ClientContext,
) -> ApiResult<Json<AccountRunningState>> {
let account_id = account_id.0;
context.require_account_access(account_id)?;
let state = AccountRunningState::get(account_id).await?.ok_or_else(|| {
raise_error!(
"account running state is not found".into(),
ErrorCode::ResourceNotFound
)
})?;
Ok(Json(state))
}
/// Get a minimal list of active accounts for use in selectors when creating account-related resources
///
/// This endpoint provides a lightweight list of accounts containing only essential information (id and name).
/// It's primarily designed for UI selectors/dropdowns when creating or associating resources with accounts.
#[oai(
path = "/minimal-account-list",
method = "get",
operation_id = "minimal_accounts_list"
)]
async fn minimal_accounts_list(
&self,
context: ClientContext,
) -> ApiResult<Json<Vec<MinimalAccount>>> {
let accessible_accounts = context.accessible_accounts()?;
let minimal_list = AccountModel::minimal_list().await?;
let result = match accessible_accounts {
Some(set) => filter_accessible_accounts(&minimal_list, set),
None => minimal_list,
};
Ok(Json(result))
}
}
+55
View File
@@ -0,0 +1,55 @@
//
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use crate::modules::autoconfig::entity::MailServerConfig;
use crate::modules::autoconfig::load::resolve_autoconfig;
use crate::modules::error::code::ErrorCode;
use crate::modules::rest::api::ApiTags;
use crate::modules::rest::ApiResult;
use crate::raise_error;
use poem::web::Path;
use poem_openapi::payload::Json;
use poem_openapi::OpenApi;
pub struct AutoConfigApi;
#[OpenApi(prefix_path = "/api/v1", tag = "ApiTags::AutoConfig")]
impl AutoConfigApi {
/// Retrieve mail server configuration for a given email address
#[oai(
path = "/autoconfig/:email_address",
method = "get",
operation_id = "autoconfig"
)]
async fn autoconfig(
&self,
/// The email address to lookup configuration for
email_address: Path<String>
) -> ApiResult<Json<MailServerConfig>> {
let result = resolve_autoconfig(email_address.0.trim())
.await?
.ok_or_else(|| {
raise_error!(
"Unable to find account configuration information in the backend.".into(),
ErrorCode::ResourceNotFound
)
})?;
Ok(Json(result))
}
}
+60
View File
@@ -0,0 +1,60 @@
//
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use crate::modules::cache::imap::mailbox::MailBox;
use crate::modules::common::auth::ClientContext;
use crate::modules::mailbox::list::get_account_mailboxes;
use crate::modules::rest::api::ApiTags;
use crate::modules::rest::ApiResult;
use poem::web::Path;
use poem_openapi::param::Query;
use poem_openapi::payload::Json;
use poem_openapi::OpenApi;
pub struct MailBoxApi;
#[OpenApi(prefix_path = "/api/v1", tag = "ApiTags::Mailbox")]
impl MailBoxApi {
/// Returns all available mailboxes for the given account.
///
/// - For IMAP/SMTP accounts, this corresponds to folders/mailboxes.
/// - For Gmail API accounts, this corresponds to labels visible via the
/// `list messages` API (serving as mailbox equivalents).
///
/// Both account types support two modes:
/// - Using the local cache of mailboxes/labels.
/// - Querying the remote service directly for the latest state.
#[oai(
path = "/list-mailboxes/:account_id",
method = "get",
operation_id = "list_mailboxes"
)]
async fn list_mailboxes(
&self,
/// The unique identifier of the account.
account_id: Path<u64>,
remote: Query<Option<bool>>,
context: ClientContext,
) -> ApiResult<Json<Vec<MailBox>>> {
let account_id = account_id.0;
context.require_account_access(account_id)?;
let remote = remote.0.unwrap_or(false);
Ok(Json(get_account_mailboxes(account_id, remote).await?))
}
}
+218
View File
@@ -0,0 +1,218 @@
//
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use crate::modules::common::auth::ClientContext;
use crate::modules::indexer::envelope::Envelope;
use crate::modules::indexer::manager::EML_INDEX_MANAGER;
use crate::modules::indexer::manager::ENVELOPE_INDEX_MANAGER;
use crate::modules::message::content::{retrieve_email_content, FullMessageContent};
use crate::modules::message::delete::delete_messages_impl;
use crate::modules::message::list::{get_thread_messages, list_messages_impl};
use crate::modules::message::search::{search_messages_impl, SearchRequest};
use crate::modules::message::tags::TagCount;
use crate::modules::message::tags::UpdateTagsRequest;
use crate::modules::rest::api::ApiTags;
use crate::modules::rest::response::DataPage;
use crate::modules::rest::ApiResult;
use crate::modules::rest::ErrorCode;
use crate::raise_error;
use poem::web::Path;
use poem::Body;
use poem_openapi::param::Query;
use poem_openapi::payload::{Attachment, AttachmentType, Json};
use poem_openapi::OpenApi;
use std::collections::HashMap;
use tantivy::schema::Facet;
pub struct MessageApi;
#[OpenApi(prefix_path = "/api/v1", tag = "ApiTags::Message")]
impl MessageApi {
/// Deletes messages from a mailbox or moves them to the trash for the specified account.
#[oai(
path = "/delete-messages",
method = "post",
operation_id = "delete_messages"
)]
async fn delete_messages(
&self,
/// specifying the mailbox and messages to delete.
payload: Json<HashMap<u64, Vec<u64>>>,
context: ClientContext,
) -> ApiResult<()> {
let request = payload.0;
for account_id in request.keys() {
context.require_account_access(*account_id)?;
}
Ok(delete_messages_impl(request).await?)
}
/// Lists messages in a specified mailbox for the given account.
#[oai(
path = "/list-messages/:account_id",
method = "get",
operation_id = "list_messages"
)]
async fn list_messages(
&self,
account_id: Path<u64>,
mailbox_id: Query<u64>,
page: Query<u64>,
page_size: Query<u64>,
context: ClientContext,
) -> ApiResult<Json<DataPage<Envelope>>> {
let account_id = account_id.0;
let mailbox_id = mailbox_id.0;
context.require_account_access(account_id)?;
Ok(Json(
list_messages_impl(account_id, mailbox_id, page.0, page_size.0).await?,
))
}
/// Lists messages in a specified mailbox for the given account.
#[oai(
path = "/search-messages",
method = "post",
operation_id = "search_messages"
)]
async fn search_messages(
&self,
payload: Json<SearchRequest>,
context: ClientContext,
) -> ApiResult<Json<DataPage<Envelope>>> {
context.require_root()?;
Ok(Json(search_messages_impl(payload.0).await?))
}
/// Get thread's envelopes in a specified mailbox for the given account.
#[oai(
path = "/get-thread-messages/:account_id",
method = "get",
operation_id = "get_thread_messages"
)]
async fn get_thread_messages(
&self,
/// The ID of the account owning the mailbox.
account_id: Path<u64>,
// Thread ID
thread_id: Query<u64>,
/// The page number for pagination (1-based).
page: Query<u64>,
/// The number of messages per page.
page_size: Query<u64>,
context: ClientContext,
) -> ApiResult<Json<DataPage<Envelope>>> {
let account_id = account_id.0;
let thread_id = thread_id.0;
context.require_account_access(account_id)?;
Ok(Json(
get_thread_messages(account_id, thread_id, page.0, page_size.0).await?,
))
}
/// Fetches the content of a specific email for the given account.
#[oai(
path = "/message-content/:account_id",
method = "get",
operation_id = "fetch_message_content"
)]
async fn fetch_message_content(
&self,
account_id: Path<u64>,
id: Query<u64>,
context: ClientContext,
) -> ApiResult<Json<FullMessageContent>> {
let account_id = account_id.0;
context.require_account_access(account_id)?;
Ok(Json(retrieve_email_content(account_id, id.0).await?))
}
/// Fetches the full content of a specific email for the given account.
#[oai(
path = "/download-message/:account_id",
method = "get",
operation_id = "download_message"
)]
async fn download_message(
&self,
account_id: Path<u64>,
id: Query<u64>,
context: ClientContext,
) -> ApiResult<Attachment<Body>> {
let account_id = account_id.0;
context.require_account_access(account_id)?;
let id = id.0;
let reader = EML_INDEX_MANAGER.get_reader(account_id, id).await?;
let body = Body::from_async_read(reader);
let attachment = Attachment::new(body)
.attachment_type(AttachmentType::Attachment)
.filename(format!("{id}.eml"));
Ok(attachment)
}
/// Downloads a specific attachment by filename.
#[oai(
path = "/download-attachment/:account_id",
method = "get",
operation_id = "download_attachment"
)]
async fn download_attachment(
&self,
account_id: Path<u64>,
id: Query<u64>,
name: Query<String>,
context: ClientContext,
) -> ApiResult<Attachment<Body>> {
let account_id = account_id.0;
context.require_account_access(account_id)?;
let email_id = id.0;
let name = name.0.trim();
let reader = EML_INDEX_MANAGER
.get_attachment(account_id, email_id, name)
.await?;
let body = Body::from_async_read(reader);
let attachment = Attachment::new(body)
.attachment_type(AttachmentType::Attachment)
.filename(name);
Ok(attachment)
}
/// Returns all facets in the index along with their document counts.
#[oai(path = "/all-tags", method = "get", operation_id = "get_all_tags")]
async fn get_all_tags(&self) -> ApiResult<Json<Vec<TagCount>>> {
Ok(Json(ENVELOPE_INDEX_MANAGER.get_all_tags().await?))
}
/// Adds or removes facet tags for multiple emails across accounts.
#[oai(
path = "/update-tags",
method = "post",
operation_id = "update_envelope_tags"
)]
async fn update_envelope_tags(&self, req: Json<UpdateTagsRequest>) -> ApiResult<()> {
let req = req.0;
for tag in &req.tags {
Facet::from_text(tag)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InvalidParameter))?;
}
ENVELOPE_INDEX_MANAGER
.update_envelope_tags(req.updates, req.tags)
.await?;
Ok(())
}
}
+74
View File
@@ -0,0 +1,74 @@
//
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use access_token::AccessTokenApi;
use account::AccountApi;
use auto_config::AutoConfigApi;
use mailbox::MailBoxApi;
use message::MessageApi;
use oauth2::OAuth2Api;
use poem_openapi::{OpenApiService, Tags};
use system::SystemApi;
use crate::bichon_version;
pub mod access_token;
pub mod account;
pub mod auto_config;
pub mod mailbox;
pub mod message;
pub mod oauth2;
pub mod system;
#[derive(Tags)]
pub enum ApiTags {
AccessToken,
AutoConfig,
Account,
Mailbox,
OAuth2,
Message,
System,
}
type RustMailOpenApi = (
AccessTokenApi,
AutoConfigApi,
AccountApi,
SystemApi,
MailBoxApi,
OAuth2Api,
MessageApi,
);
pub fn create_openapi_service() -> OpenApiService<RustMailOpenApi, ()> {
OpenApiService::new(
(
AccessTokenApi,
AutoConfigApi,
AccountApi,
SystemApi,
MailBoxApi,
OAuth2Api,
MessageApi,
),
"BichonApi",
bichon_version!(),
)
}
+228
View File
@@ -0,0 +1,228 @@
//
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use crate::modules::common::auth::ClientContext;
use crate::modules::error::code::ErrorCode;
use crate::modules::oauth2::entity::{OAuth2, OAuth2CreateRequest, OAuth2UpdateRequest};
use crate::modules::oauth2::flow::{AuthorizeUrlRequest, OAuth2Flow};
use crate::modules::oauth2::token::{ExternalOAuth2Request, OAuth2AccessToken};
use crate::modules::rest::api::ApiTags;
use crate::modules::rest::response::DataPage;
use crate::modules::rest::ApiResult;
use crate::raise_error;
use poem::web::Path;
use poem_openapi::param::Query;
use poem_openapi::payload::{Json, PlainText};
use poem_openapi::OpenApi;
pub struct OAuth2Api;
#[OpenApi(prefix_path = "/api/v1", tag = "ApiTags::OAuth2")]
impl OAuth2Api {
/// Retrieves the OAuth2 configuration for a specified name.
///
/// Requires root privileges.
/// This endpoint fetches the OAuth2 configuration identified by the given name.
#[oai(
path = "/oauth2/:id",
method = "get",
operation_id = "get_oauth2_config"
)]
async fn get_oauth2_config(
&self,
/// The name of the OAuth2 configuration to retrieve
id: Path<u64>,
context: ClientContext,
) -> ApiResult<Json<OAuth2>> {
context.require_root()?;
let id = id.0;
Ok(Json(OAuth2::get(id).await?.ok_or_else(|| {
raise_error!(
format!("OAuth2 configuration id='{id}' not found"),
ErrorCode::ResourceNotFound
)
})?))
}
/// Deletes an OAuth2 configuration by name.
///
/// Requires root privileges.
/// This endpoint removes the OAuth2 configuration identified by the specified name.
#[oai(
path = "/oauth2/:id",
method = "delete",
operation_id = "remove_oauth2_config"
)]
async fn remove_oauth2_config(
&self,
/// The name of the OAuth2 configuration to retrieve
id: Path<u64>,
context: ClientContext,
) -> ApiResult<()> {
context.require_root()?;
Ok(OAuth2::delete(id.0).await?)
}
/// Creates a new OAuth2 configuration.
///
/// Requires root privileges.
/// This endpoint creates a new OAuth2 configuration based on the provided request data.
#[oai(
path = "/oauth2",
method = "post",
operation_id = "create_oauth2_config"
)]
async fn create_oauth2_config(
&self,
/// A JSON payload containing the details for the new OAuth2 configuration
request: Json<OAuth2CreateRequest>,
context: ClientContext,
) -> ApiResult<()> {
context.require_root()?;
let entity = OAuth2::new(request.0)?;
Ok(entity.save().await?)
}
/// Updates an existing OAuth2 configuration.
///
/// Requires root privileges.
/// This endpoint updates the OAuth2 configuration identified by the specified name
#[oai(
path = "/oauth2/:id",
method = "post",
operation_id = "update_oauth2_config"
)]
async fn update_oauth2_config(
&self,
/// The name of the OAuth2 configuration to update
id: Path<u64>,
/// A JSON payload containing the updated configuration details
payload: Json<OAuth2UpdateRequest>,
context: ClientContext,
) -> ApiResult<()> {
context.require_root()?;
Ok(OAuth2::update(id.0, payload.0).await?)
}
/// Lists OAuth2 configurations with pagination and sorting options.
///
/// This endpoint retrieves a paginated list of OAuth2 configurations, allowing for
/// optional pagination and sorting parameters. It requires root access.
#[oai(
path = "/oauth2-list",
method = "get",
operation_id = "list_oauth2_config"
)]
async fn list_oauth2_config(
&self,
/// Optional. The page number to retrieve (starting from 1).
page: Query<Option<u64>>,
/// Optional. The number of items per page.
page_size: Query<Option<u64>>,
/// Optional. Whether to sort the list in descending order.
desc: Query<Option<bool>>,
context: ClientContext,
) -> ApiResult<Json<DataPage<OAuth2>>> {
context.require_root()?;
Ok(Json(
OAuth2::paginate_list(page.0, page_size.0, desc.0).await?,
))
}
/// Generates an OAuth2 authorization URL for a specific account.
///
/// This endpoint creates an authorization URL for the specified OAuth2 configuration
/// and account ID. It requires root access and returns the URL as plain text.
#[oai(
path = "/oauth2-authorize-url",
method = "post",
operation_id = "create_oauth2_authorize_url"
)]
async fn create_oauth2_authorize_url(
&self,
/// A JSON payload containing the OAuth2 configuration name and account ID.
request: Json<AuthorizeUrlRequest>,
context: ClientContext,
) -> ApiResult<PlainText<String>> {
context.require_root()?;
let request = request.0;
let flow = OAuth2Flow::new(request.oauth2_id);
Ok(PlainText(flow.authorize_url(request.account_id).await?))
}
/// Retrieves OAuth2 access tokens for a specified account.
///
/// This endpoint fetches the OAuth2 access tokens associated with the given account ID.
#[oai(
path = "/oauth2-tokens/:account_id",
method = "get",
operation_id = "get_oauth2_tokens"
)]
async fn get_oauth2_tokens(
&self,
/// The ID of the account to retrieve access tokens for
account_id: Path<u64>,
context: ClientContext,
) -> ApiResult<Json<OAuth2AccessToken>> {
let account = account_id.0;
context.require_account_access(account)?;
Ok(Json(OAuth2AccessToken::get(account).await?.ok_or_else(
|| {
raise_error!(
"OAuth2 access tokens not found".into(),
ErrorCode::ResourceNotFound
)
},
)?))
}
/// Configures an external OAuth2 token for a specified account.
///
/// This endpoint allows two usage modes:
/// 1. If only an `access_token` is provided, RustMailer will store it directly.
/// - In this mode, RustMailer **cannot refresh** the token, since it has no
/// associated OAuth2 configuration or refresh token.
/// - The caller is responsible for periodically updating the access token
/// by calling this endpoint again.
/// 2. If both `oauth2_id` and `refresh_token` are provided, it means the external
/// OAuth2 authorization flow has been completed outside RustMailer.
/// - Since the OAuth2 configuration (including client_id and client_secret)
/// is already stored in RustMailer, the service can use the refresh token
/// to obtain new access tokens automatically.
///
/// Note: The `oauth2_id` must reference a valid OAuth2 configuration
/// already created in RustMailer.
#[oai(
path = "/store-external-oauth2-token/:account_id",
method = "post",
operation_id = "store_external_oauth2_token"
)]
async fn store_external_oauth2_token(
&self,
account_id: Path<u64>,
request: Json<ExternalOAuth2Request>,
context: ClientContext,
) -> ApiResult<()> {
let account = account_id.0;
// Check account access permissions
context.require_account_access(account)?;
OAuth2AccessToken::upsert_external_oauth_token(account, request.0).await?;
Ok(())
}
}
+120
View File
@@ -0,0 +1,120 @@
//
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use crate::modules::common::auth::ClientContext;
use crate::modules::dashboard::DashboardStats;
use crate::modules::error::code::ErrorCode;
use crate::modules::rest::api::ApiTags;
use crate::modules::rest::ApiResult;
use crate::modules::settings::proxy::Proxy;
use crate::modules::version::{fetch_notifications, Notifications};
use crate::raise_error;
use poem_openapi::param::Path;
use poem_openapi::payload::{Json, PlainText};
use poem_openapi::OpenApi;
pub struct SystemApi;
#[OpenApi(prefix_path = "/api/v1", tag = "ApiTags::System")]
impl SystemApi {
/// Retrieves important system notifications for the RustMail service.
///
/// This endpoint returns a consolidated view of all critical system notifications including:
/// - Available version updates
/// - License expiration warnings
#[oai(
method = "get",
path = "/notifications",
operation_id = "get_notifications"
)]
async fn get_notifications(&self) -> ApiResult<Json<Notifications>> {
let notification = fetch_notifications()
.await
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
Ok(Json(notification))
}
/// Get overall dashboard statistics.
///
/// Returns various aggregated metrics about the mail system, such as
/// total email count, total storage size, index usage, top senders,
/// recent activity histogram, and more.
#[oai(
method = "get",
path = "/dashboard-stats",
operation_id = "get_dashboard_stats"
)]
async fn get_dashboard_stats(&self) -> ApiResult<Json<DashboardStats>> {
let stats = DashboardStats::get().await?;
Ok(Json(stats))
}
/// Get the full list of SOCKS5 proxy configurations.
#[oai(method = "get", path = "/list-proxy", operation_id = "list_proxy")]
async fn list_proxy(&self) -> ApiResult<Json<Vec<Proxy>>> {
let proxies = Proxy::list_all()
.await
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
Ok(Json(proxies))
}
/// Delete a specific proxy configuration by ID. Requires root permission.
#[oai(path = "/proxy/:id", method = "delete", operation_id = "remove_proxy")]
async fn remove_proxy(
&self,
/// The name of the OAuth2 configuration to retrieve
id: Path<u64>,
context: ClientContext,
) -> ApiResult<()> {
context.require_root()?;
Ok(Proxy::delete(id.0).await?)
}
/// Retrieve a specific proxy configuration by ID
#[oai(path = "/proxy/:id", method = "get", operation_id = "get_proxy")]
async fn get_proxy(
&self,
/// The name of the OAuth2 configuration to retrieve
id: Path<u64>,
context: ClientContext,
) -> ApiResult<Json<Proxy>> {
context.require_root()?;
Ok(Json(Proxy::get(id.0).await?))
}
/// Create a new proxy configuration. Requires root permission.
#[oai(path = "/proxy", method = "post", operation_id = "create_proxy")]
async fn create_proxy(&self, url: PlainText<String>, context: ClientContext) -> ApiResult<()> {
context.require_root()?;
let entity = Proxy::new(url.0);
Ok(entity.save().await?)
}
/// Update the URL of a specific proxy by ID. Requires root permission.
#[oai(path = "/proxy/:id", method = "post", operation_id = "update_proxy")]
async fn update_proxy(
&self,
id: Path<u64>,
url: PlainText<String>,
context: ClientContext,
) -> ApiResult<()> {
context.require_root()?;
Ok(Proxy::update(id.0, url.0).await?)
}
}