feat: add OAuth2 client credentials flow for Microsoft Graph

This commit is contained in:
rustmailer
2026-08-13 20:53:54 +08:00
parent 8d0b4919c4
commit e824dae859
16 changed files with 619 additions and 149 deletions
+7 -4
View File
@@ -7,6 +7,7 @@ use crate::modules::cache::imap::migration::EmailEnvelopeV3;
use crate::modules::cache::imap::ENVELOPE_MODELS;
use crate::modules::context::Initialize;
use crate::modules::error::{code::ErrorCode, RustMailerError};
use crate::modules::oauth2::entity::OAuth2Model;
use crate::modules::scheduler::nativedb::TaskMetaEntity;
use crate::modules::settings::cli::SETTINGS;
use crate::modules::settings::dir::{DATA_DIR_MANAGER, META_FILE, TASK_FILE};
@@ -18,8 +19,6 @@ use native_db::{Builder, Database};
use std::sync::{Arc, LazyLock};
use tracing::{info, warn};
pub static DB_MANAGER: LazyLock<DatabaseManager> = LazyLock::new(DatabaseManager::new);
use crate::modules::{
account::status::AccountRunningState,
autoconfig::CachedMailSettings,
@@ -27,13 +26,15 @@ use crate::modules::{
database::{batch_insert_impl, list_all_impl},
hook::entity::EventHooks,
license::License,
oauth2::{entity::OAuth2, pending::OAuth2PendingEntity, token::OAuth2AccessToken},
oauth2::{pending::OAuth2PendingEntity, token::OAuth2AccessToken},
overview::metrics::DailyMetrics,
settings::{proxy::Proxy, system::SystemSetting},
smtp::{mta::entity::Mta, template::entity::EmailTemplate},
token::AccessToken,
};
pub static DB_MANAGER: LazyLock<DatabaseManager> = LazyLock::new(DatabaseManager::new);
/// Metadata database instance
pub struct DatabaseManager {
meta_db: Arc<Database<'static>>,
@@ -92,6 +93,8 @@ impl DatabaseManager {
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
rw.migrate::<AccountModel>()
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
rw.migrate::<OAuth2Model>()
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
rw.commit()
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
@@ -140,7 +143,7 @@ impl DatabaseManager {
spawn_migration_task!(AccountModel);
spawn_migration_task!(EmailTemplate);
spawn_migration_task!(Mta);
spawn_migration_task!(OAuth2);
spawn_migration_task!(OAuth2Model);
spawn_migration_task!(OAuth2PendingEntity);
spawn_migration_task!(OAuth2AccessToken);
spawn_migration_task!(EventHooks);
+2 -1
View File
@@ -9,7 +9,7 @@ use crate::modules::cache::disk::CacheItem;
use crate::modules::error::RustMailerResult;
use crate::modules::hook::entity::EventHooks;
use crate::modules::license::License;
use crate::modules::oauth2::entity::OAuth2;
use crate::modules::oauth2::entity::{OAuth2, OAuth2V2};
use crate::modules::oauth2::pending::OAuth2PendingEntity;
use crate::modules::oauth2::token::OAuth2AccessToken;
use crate::modules::settings::proxy::Proxy;
@@ -65,6 +65,7 @@ impl ModelsAdapter {
self.register_model::<EmailTemplate>();
self.register_model::<Mta>();
self.register_model::<OAuth2>();
self.register_model::<OAuth2V2>();
self.register_model::<OAuth2PendingEntity>();
self.register_model::<OAuth2AccessToken>();
self.register_model::<EventHooks>();
+35 -8
View File
@@ -3,14 +3,30 @@
// Unauthorized copying, modification, or distribution is prohibited.
use crate::modules::{
grpc::service::rustmailer_grpc::{self, PagedOAuth2},
grpc::service::rustmailer_grpc::{
self, ClientCredentialsRequest, OAuth2GrantType as GrpcOAuth2GrantType, PagedOAuth2,
},
oauth2::{
entity::{OAuth2, OAuth2CreateRequest, OAuth2UpdateRequest},
entity::{OAuth2CreateRequest, OAuth2GrantType, OAuth2Model, OAuth2UpdateRequest},
token::{ExternalOAuth2Request, OAuth2AccessToken},
},
rest::response::DataPage,
};
fn grpc_grant_type_to_domain(v: i32) -> OAuth2GrantType {
match GrpcOAuth2GrantType::try_from(v) {
Ok(GrpcOAuth2GrantType::ClientCredentials) => OAuth2GrantType::ClientCredentials,
_ => OAuth2GrantType::AuthorizationCode,
}
}
fn grpc_grant_type_to_optional(v: Option<i32>) -> Option<OAuth2GrantType> {
match v {
Some(code) => Some(grpc_grant_type_to_domain(code)),
None => None,
}
}
impl From<rustmailer_grpc::OAuth2CreateRequest> for OAuth2CreateRequest {
fn from(value: rustmailer_grpc::OAuth2CreateRequest) -> Self {
Self {
@@ -25,11 +41,11 @@ impl From<rustmailer_grpc::OAuth2CreateRequest> for OAuth2CreateRequest {
.then(|| value.extra_params.into_iter().collect()),
enabled: value.enabled,
use_proxy: value.use_proxy,
grant_type: grpc_grant_type_to_domain(value.grant_type),
}
}
}
// 2. From RustMailerOAuth2 to OAuth2 (gRPC)
impl From<rustmailer_grpc::UpdateOAuth2Request> for OAuth2UpdateRequest {
fn from(value: rustmailer_grpc::UpdateOAuth2Request) -> Self {
Self {
@@ -44,13 +60,13 @@ impl From<rustmailer_grpc::UpdateOAuth2Request> for OAuth2UpdateRequest {
.then(|| value.extra_params.into_iter().collect()),
enabled: value.enabled,
use_proxy: value.use_proxy,
grant_type: grpc_grant_type_to_optional(value.grant_type),
}
}
}
// 3. From UpdateOAuth2Request (gRPC) to OAuth2UpdateRequestDomain
impl From<OAuth2> for rustmailer_grpc::OAuth2 {
fn from(value: OAuth2) -> Self {
impl From<OAuth2Model> for rustmailer_grpc::OAuth2 {
fn from(value: OAuth2Model) -> Self {
Self {
id: value.id,
description: value.description,
@@ -68,12 +84,16 @@ impl From<OAuth2> for rustmailer_grpc::OAuth2 {
use_proxy: value.use_proxy,
created_at: value.created_at,
updated_at: value.updated_at,
grant_type: match value.grant_type {
OAuth2GrantType::ClientCredentials => GrpcOAuth2GrantType::ClientCredentials as i32,
_ => GrpcOAuth2GrantType::AuthorizationCode as i32,
},
}
}
}
impl From<DataPage<OAuth2>> for PagedOAuth2 {
fn from(value: DataPage<OAuth2>) -> Self {
impl From<DataPage<OAuth2Model>> for PagedOAuth2 {
fn from(value: DataPage<OAuth2Model>) -> Self {
Self {
current_page: value.current_page,
page_size: value.page_size,
@@ -96,6 +116,7 @@ impl From<OAuth2AccessToken> for rustmailer_grpc::OAuth2AccessToken {
}
}
}
impl From<rustmailer_grpc::ExternalOAuth2Request> for ExternalOAuth2Request {
fn from(value: rustmailer_grpc::ExternalOAuth2Request) -> Self {
Self {
@@ -105,3 +126,9 @@ impl From<rustmailer_grpc::ExternalOAuth2Request> for ExternalOAuth2Request {
}
}
}
impl From<ClientCredentialsRequest> for (u64, u64) {
fn from(value: ClientCredentialsRequest) -> Self {
(value.account_id, value.oauth2_id)
}
}
+20 -7
View File
@@ -5,12 +5,12 @@
use crate::modules::error::code::ErrorCode;
use crate::modules::grpc::auth::{require_account_access, require_root};
use crate::modules::grpc::service::rustmailer_grpc::{
AuthorizeUrlRequest, AuthorizeUrlResponse, DeleteOAuth2Request, Empty, ExternalOAuth2Request,
GetOAuth2Request, GetOAuth2TokensRequest, ListOAuth2Request, OAuth2, OAuth2AccessToken,
OAuth2CreateRequest, OAuth2Service, PagedOAuth2, UpdateOAuth2Request,
ClientCredentialsRequest, DeleteOAuth2Request, Empty, ExternalOAuth2Request, GetOAuth2Request,
GetOAuth2TokensRequest, ListOAuth2Request, OAuth2, OAuth2AccessToken, OAuth2CreateRequest,
OAuth2Service, PagedOAuth2, UpdateOAuth2Request,
};
use crate::modules::oauth2::{
entity::OAuth2 as RustMailerOAuth2, flow::OAuth2Flow,
entity::OAuth2Model as RustMailerOAuth2, flow::OAuth2Flow,
token::OAuth2AccessToken as RustMailerOAuth2AccessToken,
};
use crate::raise_error;
@@ -75,12 +75,14 @@ impl OAuth2Service for RustMailerOAuth2Service {
async fn create_authorize_url(
&self,
request: Request<AuthorizeUrlRequest>,
) -> Result<Response<AuthorizeUrlResponse>, Status> {
request: Request<crate::modules::grpc::service::rustmailer_grpc::AuthorizeUrlRequest>,
) -> Result<Response<crate::modules::grpc::service::rustmailer_grpc::AuthorizeUrlResponse>, Status> {
let req = require_root(request)?;
let flow = OAuth2Flow::new(req.oauth2_id);
let url = flow.authorize_url(req.account_id).await?;
Ok(Response::new(AuthorizeUrlResponse { url }))
Ok(Response::new(crate::modules::grpc::service::rustmailer_grpc::AuthorizeUrlResponse {
url,
}))
}
async fn get_o_auth2_tokens(
@@ -99,6 +101,17 @@ impl OAuth2Service for RustMailerOAuth2Service {
Ok(Response::new(result.into()))
}
async fn exchange_client_credentials(
&self,
request: Request<ClientCredentialsRequest>,
) -> Result<Response<Empty>, Status> {
let req = require_account_access(request, |r| r.account_id)?;
let (account_id, oauth2_id) = req.into();
let flow = OAuth2Flow::new(oauth2_id);
flow.exchange_client_credentials(account_id).await?;
Ok(Response::new(Empty::default()))
}
async fn upsert_external_o_auth2_token(
&self,
request: Request<ExternalOAuth2Request>,
+118 -9
View File
@@ -16,10 +16,22 @@ use crate::{
};
use native_db::*;
use native_model::{native_model, Model};
use poem_openapi::Object;
use poem_openapi::{Enum, Object};
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
pub type OAuth2Model = OAuth2V2;
/// The grant type used by this OAuth2 configuration.
#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize, Enum)]
pub enum OAuth2GrantType {
/// Authorization Code Flow with PKCE — requires user interaction.
#[default]
AuthorizationCode,
/// Client Credentials Flow — no user interaction, suitable for service-to-service auth.
ClientCredentials,
}
/// Represents the OAuth2 configuration for a client, including initialization and runtime values.
#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize, Object)]
#[native_model(id = 8, version = 1)]
@@ -57,15 +69,60 @@ pub struct OAuth2 {
/// The timestamp when the configuration was last updated, in milliseconds since the Unix epoch.
pub updated_at: i64,
}
impl OAuth2 {
fn pk(&self) -> String {
format!("{}_{}", &self.created_at, &self.id)
}
}
/// Represents the OAuth2 configuration for a client, including initialization and runtime values.
#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize, Object)]
#[native_model(id = 8, version = 2, from = OAuth2)]
#[native_db(primary_key(pk -> String))]
pub struct OAuth2V2 {
/// A unique identifier for the OAuth2 configuration.
#[secondary_key(unique)]
pub id: u64,
/// A description of what this configuration is used for.
pub description: Option<String>,
/// The client ID used for authenticating the application with the OAuth2 provider.
pub client_id: String,
/// The client secret used in conjunction with the client ID.
///
/// Users should provide a plaintext secret.
/// The server will encrypt it using AES-256-GCM and securely store it.
/// The plaintext secret is never stored, so users must ensure it is valid for OAuth2 authentication.
pub client_secret: String,
/// The URL to redirect users to for OAuth2 authorization.
pub auth_url: String,
/// The URL to exchange authorization codes for access tokens.
pub token_url: String,
/// The URI where the OAuth2 provider will redirect to after authorization.
pub redirect_uri: String,
/// The scopes of access that are being requested (e.g., email, profile).
pub scopes: Option<Vec<String>>,
/// Any additional parameters to include in the OAuth2 requests (e.g., access_type, prompt).
pub extra_params: Option<BTreeMap<String, String>>,
/// Indicates whether this configuration is enabled or disabled.
pub enabled: bool,
/// route OAuth through proxy (when direct access is blocked)
pub use_proxy: Option<u64>,
/// The grant type used for this OAuth2 configuration.
pub grant_type: OAuth2GrantType,
/// The timestamp when the configuration was created, in milliseconds since the Unix epoch.
pub created_at: i64,
/// The timestamp when the configuration was last updated, in milliseconds since the Unix epoch.
pub updated_at: i64,
}
impl OAuth2V2 {
fn pk(&self) -> String {
format!("{}_{}", &self.created_at, &self.id)
}
pub fn new(request: OAuth2CreateRequest) -> RustMailerResult<Self> {
let request = request.encrypt()?;
Ok(OAuth2 {
Ok(OAuth2Model {
id: id!(64),
description: request.description,
client_id: request.client_id,
@@ -79,6 +136,7 @@ impl OAuth2 {
created_at: utc_now!(),
updated_at: utc_now!(),
use_proxy: request.use_proxy,
grant_type: request.grant_type,
})
}
@@ -91,20 +149,20 @@ impl OAuth2 {
page: Option<u64>,
page_size: Option<u64>,
desc: Option<bool>,
) -> RustMailerResult<DataPage<OAuth2>> {
) -> RustMailerResult<DataPage<OAuth2Model>> {
paginate_query_primary_scan_all_impl(DB_MANAGER.meta_db(), page, page_size, desc)
.await
.map(DataPage::from)
}
pub async fn get(id: u64) -> RustMailerResult<Option<OAuth2>> {
secondary_find_impl(DB_MANAGER.meta_db(), OAuth2Key::id, id).await
pub async fn get(id: u64) -> RustMailerResult<Option<OAuth2Model>> {
secondary_find_impl(DB_MANAGER.meta_db(), OAuth2V2Key::id, id).await
}
pub async fn delete(id: u64) -> RustMailerResult<()> {
delete_impl(DB_MANAGER.meta_db(), move |rw| {
rw.get()
.secondary::<OAuth2>(OAuth2Key::id, id)
.secondary::<OAuth2Model>(OAuth2V2Key::id, id)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
.ok_or_else(|| {
raise_error!(
@@ -123,7 +181,7 @@ impl OAuth2 {
DB_MANAGER.meta_db(),
move |rw| {
rw.get()
.secondary::<OAuth2>(OAuth2Key::id, id)
.secondary::<OAuth2Model>(OAuth2V2Key::id, id)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
.ok_or_else(|| {
raise_error!(
@@ -171,6 +229,9 @@ pub struct OAuth2CreateRequest {
/// route OAuth through proxy (when direct access is blocked)
pub use_proxy: Option<u64>,
/// The grant type to use for authentication.
pub grant_type: OAuth2GrantType,
}
impl OAuth2CreateRequest {
@@ -186,6 +247,7 @@ impl OAuth2CreateRequest {
extra_params: self.extra_params,
enabled: self.enabled,
use_proxy: self.use_proxy,
grant_type: self.grant_type,
})
}
}
@@ -221,9 +283,12 @@ pub struct OAuth2UpdateRequest {
/// route OAuth through proxy (when direct access is blocked)
pub use_proxy: Option<u64>,
/// The grant type to use for authentication.
pub grant_type: Option<OAuth2GrantType>,
}
fn apply_update(old: &OAuth2, request: OAuth2UpdateRequest) -> RustMailerResult<OAuth2> {
fn apply_update(old: &OAuth2Model, request: OAuth2UpdateRequest) -> RustMailerResult<OAuth2Model> {
let mut new = old.clone();
if request.description.is_some() {
new.description = request.description;
@@ -255,6 +320,50 @@ fn apply_update(old: &OAuth2, request: OAuth2UpdateRequest) -> RustMailerResult<
if let Some(use_proxy) = request.use_proxy {
new.use_proxy = Some(use_proxy);
}
if let Some(grant_type) = request.grant_type {
new.grant_type = grant_type;
}
new.updated_at = utc_now!();
Ok(new)
}
impl From<OAuth2> for OAuth2V2 {
fn from(value: OAuth2) -> Self {
Self {
id: value.id,
description: value.description,
client_id: value.client_id,
client_secret: value.client_secret,
auth_url: value.auth_url,
token_url: value.token_url,
redirect_uri: value.redirect_uri,
scopes: value.scopes,
extra_params: value.extra_params,
enabled: value.enabled,
use_proxy: value.use_proxy,
grant_type: OAuth2GrantType::AuthorizationCode,
created_at: value.created_at,
updated_at: value.updated_at,
}
}
}
impl From<OAuth2V2> for OAuth2 {
fn from(value: OAuth2V2) -> Self {
Self {
id: value.id,
description: value.description,
client_id: value.client_id,
client_secret: value.client_secret,
auth_url: value.auth_url,
token_url: value.token_url,
redirect_uri: value.redirect_uri,
scopes: value.scopes,
extra_params: value.extra_params,
enabled: value.enabled,
use_proxy: value.use_proxy,
created_at: value.created_at,
updated_at: value.updated_at,
}
}
}
+96 -18
View File
@@ -4,9 +4,8 @@
use crate::modules::error::code::ErrorCode;
use crate::modules::error::RustMailerResult;
use crate::modules::oauth2::{
entity::OAuth2, pending::OAuth2PendingEntity, token::OAuth2AccessToken,
};
use crate::modules::oauth2::entity::{OAuth2GrantType, OAuth2Model};
use crate::modules::oauth2::{pending::OAuth2PendingEntity, token::OAuth2AccessToken};
use crate::modules::settings::proxy::Proxy;
use crate::{decrypt, encrypt, raise_error};
use oauth2::{
@@ -50,7 +49,6 @@ impl OAuth2Flow {
}
pub async fn authorize_url(&self, account_id: u64) -> RustMailerResult<String> {
// Fetch OAuth2 entity or return a custom error if not found
let entity = self.fetch_oauth2_entity().await?;
if !entity.enabled {
@@ -62,11 +60,9 @@ impl OAuth2Flow {
ErrorCode::OAuth2ItemDisabled
));
}
// Create and configure the OAuth2 client
let client = self.build_oauth2_client(&entity)?;
// Generate PKCE challenge and verifier
let (pkce_code_challenge, pkce_code_verifier) = PkceCodeChallenge::new_random_sha256();
// Build the authorization URL request
let mut request = client
.authorize_url(CsrfToken::new_random)
@@ -78,22 +74,20 @@ impl OAuth2Flow {
.into_iter()
.map(Scope::new),
);
// Add extra parameters
if let Some(extra_params) = &entity.extra_params {
for (name, value) in extra_params {
request = request.add_extra_param(name.clone(), value.clone());
}
}
// Extract authorization URL and CSRF state
let (authorize_url, csrf_state) = request.url();
// Save the pending OAuth2 state
self.save_pending_oauth2_state(
account_id,
csrf_state.secret(),
pkce_code_verifier.secret(),
)
.await?;
// Return the authorization URL
Ok(authorize_url.to_string())
}
@@ -132,6 +126,57 @@ impl OAuth2Flow {
Ok(())
}
/// Exchanges client credentials for an access token and stores it.
/// Suitable for grant_type = ClientCredentials (e.g. Microsoft Graph v2).
pub async fn exchange_client_credentials(&self, account_id: u64) -> RustMailerResult<()> {
let entity = self.fetch_oauth2_entity().await?;
if !matches!(entity.grant_type, OAuth2GrantType::ClientCredentials) {
return Err(raise_error!(
format!(
"OAuth2 configuration id={} does not use ClientCredentials grant type.",
self.oauth2_id
),
ErrorCode::InvalidParameter
));
}
if !entity.enabled {
return Err(raise_error!(
format!(
"OAuth2 authentication is disabled for this client '{}'.",
self.oauth2_id
),
ErrorCode::OAuth2ItemDisabled
));
}
let client = self.build_oauth2_client(&entity)?;
let http_client = build_http_client(entity.use_proxy).await?;
let scopes: Vec<Scope> = entity
.scopes
.clone()
.unwrap_or_default()
.into_iter()
.map(Scope::new)
.collect();
let token_response = client
.exchange_client_credentials()
.add_scopes(scopes)
.request_async(&http_client)
.await
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::HttpResponseError))?;
let access_token = token_response.access_token().secret().to_owned();
self.save_oauth2_entity_creds(account_id, access_token)
.await?;
Ok(())
}
async fn save_oauth2_entity(
&self,
account_id: u64,
@@ -143,6 +188,15 @@ impl OAuth2Flow {
token.save_or_update().await
}
async fn save_oauth2_entity_creds(
&self,
account_id: u64,
access_token: String,
) -> RustMailerResult<()> {
let token = OAuth2AccessToken::create_creds(account_id, self.oauth2_id, access_token)?;
token.save_or_update().await
}
async fn update_oauth2_entity(
&self,
account_id: u64,
@@ -166,7 +220,23 @@ impl OAuth2Flow {
ErrorCode::OAuth2ItemDisabled
));
}
let client = self.build_oauth2_client(&entity)?;
match &entity.grant_type {
crate::modules::oauth2::entity::OAuth2GrantType::ClientCredentials => {
self.exchange_client_credentials(token.account_id).await
}
crate::modules::oauth2::entity::OAuth2GrantType::AuthorizationCode => {
self.refresh_authorization_code_token(&entity, token).await
}
}
}
async fn refresh_authorization_code_token(
&self,
entity: &OAuth2Model,
token: &OAuth2AccessToken,
) -> RustMailerResult<()> {
let client = self.build_oauth2_client(entity)?;
let http_client = build_http_client(entity.use_proxy).await?;
let refresh_token = token.refresh_token.clone().ok_or_else(|| {
@@ -181,7 +251,8 @@ impl OAuth2Flow {
.add_scopes(
entity
.scopes
.unwrap_or(Vec::new())
.clone()
.unwrap_or_default()
.into_iter()
.map(Scope::new),
)
@@ -209,8 +280,8 @@ impl OAuth2Flow {
}
// Helper function to fetch the OAuth2 entity
async fn fetch_oauth2_entity(&self) -> RustMailerResult<OAuth2> {
OAuth2::get(self.oauth2_id).await?.ok_or_else(|| {
async fn fetch_oauth2_entity(&self) -> RustMailerResult<OAuth2Model> {
OAuth2Model::get(self.oauth2_id).await?.ok_or_else(|| {
raise_error!(
format!("OAuth2 entity with id '{}' not found", self.oauth2_id),
ErrorCode::ResourceNotFound
@@ -219,15 +290,22 @@ impl OAuth2Flow {
}
// Helper function to build the OAuth2 client
fn build_oauth2_client(&self, entity: &OAuth2) -> RustMailerResult<OAuth2Client> {
fn build_oauth2_client(&self, entity: &OAuth2Model) -> RustMailerResult<OAuth2Client> {
let auth_url = AuthUrl::new(entity.auth_url.clone())
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InvalidParameter))?;
let token_url = TokenUrl::new(entity.token_url.clone())
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InvalidParameter))?;
let redirect_uri = RedirectUrl::new(entity.redirect_uri.clone())
// Client credentials flow does not use a redirect URI; `oauth2` crate still
// requires one on the client builder, so use a placeholder that is never sent.
let redirect_uri_str = if entity.redirect_uri.trim().is_empty() {
"http://localhost/oauth2/callback"
} else {
entity.redirect_uri.as_str()
};
let redirect_uri = RedirectUrl::new(redirect_uri_str.to_owned())
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InvalidParameter))?;
// Create and return the OAuth2 client
let client = BasicClient::new(ClientId::new(entity.client_id.clone()))
.set_client_secret(ClientSecret::new(decrypt!(&entity.client_secret)?))
.set_auth_uri(auth_url)
+17 -2
View File
@@ -10,7 +10,7 @@ use crate::{
update_impl, upsert_impl,
},
error::{code::ErrorCode, RustMailerResult},
oauth2::entity::OAuth2,
oauth2::entity::OAuth2Model,
},
raise_error, utc_now,
};
@@ -58,6 +58,21 @@ impl OAuth2AccessToken {
})
}
pub fn create_creds(
account_id: u64,
oauth2_id: u64,
access_token: String,
) -> RustMailerResult<Self> {
Ok(Self {
account_id,
oauth2_id,
access_token: Some(encrypt!(&access_token)?),
refresh_token: None,
created_at: utc_now!(),
updated_at: utc_now!(),
})
}
pub async fn upsert_external_oauth_token(
account_id: u64,
request: ExternalOAuth2Request,
@@ -215,7 +230,7 @@ impl ExternalOAuth2Request {
// Validate that oauth2_id exists in the database if provided
if let Some(oauth2_id) = self.oauth2_id {
let oauth2 = OAuth2::get(oauth2_id).await?;
let oauth2 = OAuth2Model::get(oauth2_id).await?;
if oauth2.is_none() {
return Err(raise_error!(
format!("OAuth2 configuration with id {} does not exist", oauth2_id),
+64 -11
View File
@@ -4,7 +4,7 @@
use crate::modules::common::auth::ClientContext;
use crate::modules::error::code::ErrorCode;
use crate::modules::oauth2::entity::{OAuth2, OAuth2CreateRequest, OAuth2UpdateRequest};
use crate::modules::oauth2::entity::{OAuth2CreateRequest, OAuth2Model, OAuth2UpdateRequest};
use crate::modules::oauth2::flow::{AuthorizeUrlRequest, OAuth2Flow};
use crate::modules::oauth2::token::{ExternalOAuth2Request, OAuth2AccessToken};
use crate::modules::rest::api::ApiTags;
@@ -12,9 +12,12 @@ 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;
use poem_openapi::{
param::Query,
payload::{Json, PlainText},
Object, OpenApi,
};
use serde::{Deserialize, Serialize};
pub struct OAuth2Api;
@@ -33,10 +36,10 @@ impl OAuth2Api {
/// The name of the OAuth2 configuration to retrieve
id: Path<u64>,
context: ClientContext,
) -> ApiResult<Json<OAuth2>> {
) -> ApiResult<Json<OAuth2Model>> {
context.require_root()?;
let id = id.0;
Ok(Json(OAuth2::get(id).await?.ok_or_else(|| {
Ok(Json(OAuth2Model::get(id).await?.ok_or_else(|| {
raise_error!(
format!("OAuth2 configuration id='{id}' not found"),
ErrorCode::ResourceNotFound
@@ -59,7 +62,7 @@ impl OAuth2Api {
context: ClientContext,
) -> ApiResult<()> {
context.require_root()?;
Ok(OAuth2::delete(id.0).await?)
Ok(OAuth2Model::delete(id.0).await?)
}
/// Creates a new OAuth2 configuration.
@@ -77,7 +80,7 @@ impl OAuth2Api {
context: ClientContext,
) -> ApiResult<()> {
context.require_root()?;
let entity = OAuth2::new(request.0)?;
let entity = OAuth2Model::new(request.0)?;
Ok(entity.save().await?)
}
@@ -98,7 +101,7 @@ impl OAuth2Api {
context: ClientContext,
) -> ApiResult<()> {
context.require_root()?;
Ok(OAuth2::update(id.0, payload.0).await?)
Ok(OAuth2Model::update(id.0, payload.0).await?)
}
/// Lists OAuth2 configurations with pagination and sorting options.
@@ -118,10 +121,10 @@ impl OAuth2Api {
/// Optional. Whether to sort the list in descending order.
desc: Query<Option<bool>>,
context: ClientContext,
) -> ApiResult<Json<DataPage<OAuth2>>> {
) -> ApiResult<Json<DataPage<OAuth2Model>>> {
context.require_root()?;
Ok(Json(
OAuth2::paginate_list(page.0, page_size.0, desc.0).await?,
OAuth2Model::paginate_list(page.0, page_size.0, desc.0).await?,
))
}
@@ -202,4 +205,54 @@ impl OAuth2Api {
Ok(())
}
/// Exchanges client credentials for an access token.
/// Only applicable for OAuth2 configurations with grant_type = CLIENT_CREDENTIALS.
#[oai(
path = "/oauth2-exchange-client-credentials",
method = "post",
operation_id = "exchange_client_credentials"
)]
async fn exchange_client_credentials(
&self,
request: Json<ExchangeClientCredentialsRequest>,
context: ClientContext,
) -> ApiResult<()> {
context.require_account_access(request.account_id)?;
let flow = OAuth2Flow::new(request.oauth2_id);
flow.exchange_client_credentials(request.account_id).await?;
Ok(())
}
/// Refreshes the OAuth2 access token for a specified account.
/// Uses the refresh token for AuthorizationCode configs, or re-exchanges
/// client credentials for ClientCredentials configs.
#[oai(
path = "/oauth2-tokens/:account_id/refresh",
method = "post",
operation_id = "refresh_oauth2_token"
)]
async fn refresh_oauth2_token(
&self,
account_id: Path<u64>,
context: ClientContext,
) -> ApiResult<()> {
let account = account_id.0;
context.require_account_access(account)?;
let token = OAuth2AccessToken::get(account).await?.ok_or_else(|| {
raise_error!(
"OAuth2 access tokens not found".into(),
ErrorCode::ResourceNotFound
)
})?;
let flow = OAuth2Flow::new(token.oauth2_id);
flow.refresh_access_token(&token).await?;
Ok(())
}
}
#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize, Object)]
pub struct ExchangeClientCredentialsRequest {
pub account_id: u64,
pub oauth2_id: u64,
}
+2 -2
View File
@@ -38,7 +38,7 @@ pub mod response;
pub type ApiResult<T, E = ApiErrorResponse> = std::result::Result<T, E>;
const DESCRIPTION: &str = r#"
RustMailer is a self-hosted IMAP/SMTP middleware platform designed for developers and businesses seeking a robust, scalable, and secure email solution.
RustMailer is a self-hosted IMAP, SMTP, Gmail API, Graph API middleware platform designed for developers and businesses seeking a robust, scalable, and secure email solution.
- Provides seamless IMAP synchronization and reliable SMTP sending via blazing-fast REST and gRPC APIs.
- Supports programmable email workflows, customizable filters, and webhook notifications.
@@ -67,7 +67,7 @@ pub async fn start_http_server() -> RustMailerResult<()> {
.contact(ContactObject::new().email("rustmailer.git@gmail.com"))
.license("https://rustmailer.com/license")
.external_document("https://rustmailer.com/docs")
.summary("A self-hosted IMAP/SMTP middleware designed for developers");
.summary("A self-hosted Email Middleware for IMAP, SMTP, Gmail API, Graph API — built for developers");
let swagger = api_service.swagger_ui();
let redoc = api_service.redoc();