diff --git a/Cargo.lock b/Cargo.lock index 21301f7..932ff7f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3815,7 +3815,7 @@ dependencies = [ "native_db_macro", "native_model", "redb 1.5.1", - "redb 2.6.2", + "redb 2.6.3", "semver 1.0.27", "serde", "skeptic", @@ -5340,9 +5340,9 @@ dependencies = [ [[package]] name = "redb" -version = "2.6.2" +version = "2.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59b38b05028f398f08bea4691640503ec25fcb60b82fb61ce1f8fd1f4fccd3f7" +checksum = "8eca1e9d98d5a7e9002d0013e18d5a9b000aee942eb134883a82f06ebffb6c01" dependencies = [ "libc", ] @@ -5814,7 +5814,7 @@ dependencies = [ "prost-types 0.14.3", "pulldown-cmark 0.13.0", "rand 0.9.2", - "redb 2.6.2", + "redb 2.6.3", "regex", "reqwest", "ring", diff --git a/Cargo.toml b/Cargo.toml index 59f20b5..196a08f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,7 +23,7 @@ chrono = "0.4.42" clap = { version = "4.5.51", features = ["derive", "env"] } mimalloc = "0.1.48" native_db = "0.8.2" -redb = "2.6.2" +redb = "2.6.3" itertools = "0.14.0" native_model = "0.4.20" poem = { version = "3.1.12", features = ["embed", "compression", "rustls"] } diff --git a/src/main.rs b/src/main.rs index 8d1f081..ff7b9ee 100644 --- a/src/main.rs +++ b/src/main.rs @@ -4,7 +4,7 @@ use mimalloc::MiMalloc; use modules::{ - common::rustls::RustMailerTls, + common::{rustls::RustMailerTls, watchdog::init_watchdog}, context::{executors::EmailClientExecutors, Initialize}, error::{code::ErrorCode, RustMailerResult}, grpc::server::start_grpc_server, @@ -47,7 +47,9 @@ async fn main() -> RustMailerResult<()> { info!("Git: [{}]", env!("GIT_HASH")); info!("Project: https://rustmailer.com"); info!("GitHub: https://github.com/rustmailer/rustmailer"); - + + init_watchdog(SETTINGS.rustmailer_watchdog_timeout_secs); + if let Err(error) = initialize().await { eprintln!("{:?}", error); return Err(error); diff --git a/src/modules/account/migration.rs b/src/modules/account/migration.rs index 41f2c27..1796059 100644 --- a/src/modules/account/migration.rs +++ b/src/modules/account/migration.rs @@ -488,7 +488,6 @@ impl AccountV3 { let result = list_all_impl(DB_MANAGER.meta_db()) .await? .into_iter() - .filter(|a: &AccountModel| a.enabled) .map(|account: AccountModel| MinimalAccount { id: account.id, email: account.email, diff --git a/src/modules/common/auth.rs b/src/modules/common/auth.rs index ef9a5a4..4352d64 100644 --- a/src/modules/common/auth.rs +++ b/src/modules/common/auth.rs @@ -164,7 +164,7 @@ pub async fn extract_client_context(req: &Request) -> Result { })?; // Check for root token - if let Ok(Some(root)) = SystemSetting::get(ROOT_TOKEN) { + if let Ok(Some(root)) = SystemSetting::get(ROOT_TOKEN).await { if root.value == token { return Ok(ClientContext { ip_addr: Some(ip_addr), diff --git a/src/modules/common/mod.rs b/src/modules/common/mod.rs index ec6718c..554a4fa 100644 --- a/src/modules/common/mod.rs +++ b/src/modules/common/mod.rs @@ -33,6 +33,7 @@ pub mod signal; pub mod timeout; pub mod tls; pub mod validator; +pub mod watchdog; #[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize, Object)] pub struct Addr { diff --git a/src/modules/common/watchdog.rs b/src/modules/common/watchdog.rs new file mode 100644 index 0000000..54fb9a2 --- /dev/null +++ b/src/modules/common/watchdog.rs @@ -0,0 +1,61 @@ +// Copyright © 2025-2026 rustmailer.com +// Licensed under RustMailer License Agreement v1.0 +// Unauthorized copying, modification, or distribution is prohibited. + +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::Duration; +use tracing::{error, info}; + +use crate::utc_now; + +/// Last time the tokio runtime proved it was alive (milliseconds since UNIX_EPOCH). +/// Updated by the heartbeat task; checked by the watchdog thread. +pub static LAST_HEARTBEAT: AtomicU64 = AtomicU64::new(0); + +/// Starts the runtime watchdog. +/// +/// A tokio task refreshes `LAST_HEARTBEAT` on an interval. A native OS thread +/// (independent of the tokio runtime) aborts the process with a non-zero exit +/// code when the heartbeat is stale for longer than `timeout_secs`, so that +/// orchestrators (Docker restart policies, supervisors) can recover the +/// process from a wedged runtime. No-op when `timeout_secs` is 0. +pub fn init_watchdog(timeout_secs: u64) { + if timeout_secs == 0 { + info!("[watchdog] disabled (timeout=0)"); + return; + } + + let heartbeat_interval = Duration::from_secs(5); + let timeout = Duration::from_secs(timeout_secs); + + tokio::spawn(async move { + let mut tick = tokio::time::interval(heartbeat_interval); + tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + loop { + tick.tick().await; + LAST_HEARTBEAT.store(now_ms(), Ordering::Relaxed); + } + }); + + std::thread::spawn(move || { + info!( + "[watchdog] started, heartbeat interval {:?}, timeout {:?}", + heartbeat_interval, timeout + ); + loop { + std::thread::sleep(heartbeat_interval); + let last = LAST_HEARTBEAT.load(Ordering::Relaxed); + if last != 0 && now_ms().saturating_sub(last) > timeout.as_millis() as u64 { + error!( + "[watchdog] runtime heartbeat stalled for {:?}, aborting the process", + timeout + ); + std::process::exit(1); + } + } + }); +} + +fn now_ms() -> u64 { + utc_now!() as u64 +} diff --git a/src/modules/context/tasks.rs b/src/modules/context/tasks.rs index 047dfa4..5d7f188 100644 --- a/src/modules/context/tasks.rs +++ b/src/modules/context/tasks.rs @@ -36,10 +36,10 @@ impl RustMailTask for ImapHeartBeatTask { } async fn touch_connection() -> RustMailerResult<()> { - let accounts = AccountModel::minimal_list().await?; + let accounts = AccountModel::list_all().await?; let imap_account_ids: Vec = accounts .into_iter() - .filter(|a| matches!(a.mailer_type, MailerType::ImapSmtp)) + .filter(|a| a.enabled && matches!(a.mailer_type, MailerType::ImapSmtp)) .map(|a| a.id) .collect(); diff --git a/src/modules/database/mod.rs b/src/modules/database/mod.rs index eb4ae8b..0b1eafe 100644 --- a/src/modules/database/mod.rs +++ b/src/modules/database/mod.rs @@ -247,21 +247,6 @@ pub async fn async_find_impl( .map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))? } -pub fn find_impl( - database: &Arc>, - key: &str, -) -> RustMailerResult> { - let db = database.clone(); - let r_transaction = db - .r_transaction() - .map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?; - let entity: Option = r_transaction - .get() - .primary(key) - .map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?; - Ok(entity) -} - pub async fn delete_impl( database: &Arc>, delete: impl FnOnce(&RwTransaction) -> RustMailerResult + Send + 'static, diff --git a/src/modules/grpc/service/account/mod.rs b/src/modules/grpc/service/account/mod.rs index fbc4e40..f965eef 100644 --- a/src/modules/grpc/service/account/mod.rs +++ b/src/modules/grpc/service/account/mod.rs @@ -4,6 +4,7 @@ use crate::modules::account::migration::AccountModel as RustMailerAccount; use crate::modules::account::payload::filter_accessible_accounts; +use crate::modules::account::payload::MinimalAccount; use crate::modules::account::payload::AccountCreateRequest as RustMailerAccountCreateRequest; use crate::modules::account::payload::AccountUpdateRequest as RustMailerAccountUpdateRequest; use crate::modules::account::status::AccountRunningState as RustMailerAccountRunningState; @@ -169,7 +170,16 @@ impl AccountService for RustMailerAccountService { })?; let accessible_accounts = context.accessible_accounts()?; - let minimal_list = RustMailerAccount::minimal_list().await?; + let minimal_list: Vec = RustMailerAccount::list_all() + .await? + .into_iter() + .filter(|a| a.enabled) + .map(|account| MinimalAccount { + id: account.id, + email: account.email, + mailer_type: account.mailer_type, + }) + .collect(); let result = match accessible_accounts { Some(set) => filter_accessible_accounts(&minimal_list, set), diff --git a/src/modules/mailbox/list.rs b/src/modules/mailbox/list.rs index 77c7288..8c4975f 100644 --- a/src/modules/mailbox/list.rs +++ b/src/modules/mailbox/list.rs @@ -23,9 +23,16 @@ pub async fn get_account_mailboxes( account_id: u64, remote: bool, ) -> RustMailerResult> { - let account = AccountModel::check_account_active(account_id, false).await?; + let account = AccountModel::get(account_id).await?; let remote = remote || account.minimal_sync(); + if remote && !account.enabled { + return Err(raise_error!( + format!("Account id='{account_id}' is disabled"), + ErrorCode::AccountDisabled + )); + } + match (&account.mailer_type, remote) { (MailerType::ImapSmtp, true) => request_imap_all_mailbox_list(account_id).await, (MailerType::ImapSmtp, false) => MailBox::list_all(account_id).await, diff --git a/src/modules/message/attachment.rs b/src/modules/message/attachment.rs index 5556043..871aaa0 100644 --- a/src/modules/message/attachment.rs +++ b/src/modules/message/attachment.rs @@ -148,7 +148,13 @@ pub async fn retrieve_email_attachment( account_id: u64, request: AttachmentRequest, ) -> RustMailerResult<(cacache::Reader, Option)> { - let account = AccountModel::check_account_active(account_id, false).await?; + let account = AccountModel::get(account_id).await?; + if !account.enabled { + return Err(raise_error!( + format!("Account id='{account_id}' is disabled"), + ErrorCode::AccountDisabled + )); + } request.validate(&account)?; match account.mailer_type { MailerType::ImapSmtp => { diff --git a/src/modules/message/content.rs b/src/modules/message/content.rs index fd3afe8..7ddad0f 100644 --- a/src/modules/message/content.rs +++ b/src/modules/message/content.rs @@ -391,7 +391,14 @@ pub async fn retrieve_email_content( request: MessageContentRequest, skip_cache: bool, ) -> RustMailerResult { - let account = AccountModel::check_account_active(account_id, false).await?; + let account = AccountModel::get(account_id).await?; + if !account.enabled { + return Err(raise_error!( + format!("Account id='{account_id}' is disabled"), + ErrorCode::AccountDisabled + )); + } + request.validate(&account)?; match account.mailer_type { diff --git a/src/modules/message/full.rs b/src/modules/message/full.rs index b48934d..2532589 100644 --- a/src/modules/message/full.rs +++ b/src/modules/message/full.rs @@ -47,7 +47,15 @@ pub async fn retrieve_raw_email( mailbox: Option<&str>, id: &str, ) -> RustMailerResult { - let account = AccountModel::check_account_active(account_id, false).await?; + let account = AccountModel::get(account_id).await?; + + if !account.enabled { + return Err(raise_error!( + format!("Account id='{account_id}' is disabled"), + ErrorCode::AccountDisabled + )); + } + match account.mailer_type { MailerType::ImapSmtp => { let mailbox = mailbox.ok_or_else(|| { diff --git a/src/modules/message/list.rs b/src/modules/message/list.rs index c1cfb9d..2c00847 100644 --- a/src/modules/message/list.rs +++ b/src/modules/message/list.rs @@ -38,7 +38,7 @@ pub async fn list_messages_in_mailbox( remote: bool, desc: bool, ) -> RustMailerResult> { - let account = AccountModel::check_account_active(account_id, false).await?; + let account = AccountModel::get(account_id).await?; if page_size == 0 { return Err(raise_error!( "page_size must be greater than 0.".into(), @@ -52,6 +52,14 @@ pub async fn list_messages_in_mailbox( )); } let remote = remote || account.minimal_sync(); + + if remote && !account.enabled { + return Err(raise_error!( + format!("Account id='{account_id}' is disabled"), + ErrorCode::AccountDisabled + )); + } + if remote { fetch_remote_messages(&account, mailbox_name, next_page_token, page_size, desc).await } else { @@ -426,7 +434,15 @@ pub async fn list_threads_in_mailbox( remote: bool, desc: bool, ) -> RustMailerResult> { - let account = AccountModel::check_account_active(account_id, false).await?; + let account = AccountModel::get(account_id).await?; + + if remote && !account.enabled { + return Err(raise_error!( + format!("Account id='{account_id}' is disabled"), + ErrorCode::AccountDisabled + )); + } + if page_size == 0 { return Err(raise_error!( "page_size must be greater than 0.".into(), @@ -689,7 +705,14 @@ pub async fn get_thread_messages( thread_id: String, remote: bool, ) -> RustMailerResult> { - let account = AccountModel::check_account_active(account_id, false).await?; + let account = AccountModel::get(account_id).await?; + + if remote && !account.enabled { + return Err(raise_error!( + format!("Account id='{account_id}' is disabled"), + ErrorCode::AccountDisabled + )); + } match account.mailer_type { MailerType::ImapSmtp => { diff --git a/src/modules/message/search/payload.rs b/src/modules/message/search/payload.rs index c2eb0a7..14bca8c 100644 --- a/src/modules/message/search/payload.rs +++ b/src/modules/message/search/payload.rs @@ -488,7 +488,14 @@ impl MessageSearchRequest { page_size: u64, desc: bool, ) -> RustMailerResult> { - let account = AccountModel::check_account_active(account_id, false).await?; + let account = AccountModel::get(account_id).await?; + if !account.enabled { + return Err(raise_error!( + format!("Account id='{account_id}' is disabled"), + ErrorCode::AccountDisabled + )); + } + match account.mailer_type { MailerType::ImapSmtp => { self.imap_search_impl(&account, next_page_token, page_size, desc) diff --git a/src/modules/message/tags.rs b/src/modules/message/tags.rs index 43a97e8..6659005 100644 --- a/src/modules/message/tags.rs +++ b/src/modules/message/tags.rs @@ -164,7 +164,14 @@ impl BatchTagRequest { } pub async fn tag_messages_impl(account_id: u64, payload: BatchTagRequest) -> RustMailerResult<()> { - let account = AccountModel::check_account_active(account_id, false).await?; + let account = AccountModel::get(account_id).await?; + if !account.enabled { + return Err(raise_error!( + format!("Account id='{account_id}' is disabled"), + ErrorCode::AccountDisabled + )); + } + let _ = &payload.validate(&account)?; match account.mailer_type { diff --git a/src/modules/message/transfer.rs b/src/modules/message/transfer.rs index d2f0c35..f587b15 100644 --- a/src/modules/message/transfer.rs +++ b/src/modules/message/transfer.rs @@ -47,8 +47,14 @@ pub async fn transfer_messages( transfer: MessageTransfer, ) -> RustMailerResult<()> { // Ensure the account exists before proceeding - let account = AccountModel::check_account_active(account_id, false).await?; - + let account = AccountModel::get(account_id).await?; + if !account.enabled { + return Err(raise_error!( + format!("Account id='{account_id}' is disabled"), + ErrorCode::AccountDisabled + )); + } + match account.mailer_type { MailerType::ImapSmtp => { if payload.ids.is_empty() { diff --git a/src/modules/oauth2/flow.rs b/src/modules/oauth2/flow.rs index d46b233..06684df 100644 --- a/src/modules/oauth2/flow.rs +++ b/src/modules/oauth2/flow.rs @@ -160,11 +160,7 @@ impl OAuth2Flow { pub async fn refresh_access_token(&self, token: &OAuth2AccessToken) -> RustMailerResult<()> { let entity = self.fetch_oauth2_entity().await?; if !entity.enabled { - OAuth2AccessToken::delete_by_oauth2_id(token.oauth2_id).await?; - return Err(raise_error!( - "OAuth2 authentication is disabled for this client".into(), - ErrorCode::OAuth2ItemDisabled - )); + return Ok(()); } let client = self.build_oauth2_client(&entity)?; let http_client = build_http_client(entity.use_proxy).await?; diff --git a/src/modules/oauth2/refresh/mod.rs b/src/modules/oauth2/refresh/mod.rs index cb50453..2cf3246 100644 --- a/src/modules/oauth2/refresh/mod.rs +++ b/src/modules/oauth2/refresh/mod.rs @@ -8,7 +8,7 @@ use crate::modules::oauth2::{flow::OAuth2Flow, token::OAuth2AccessToken}; use crate::modules::scheduler::periodic::PeriodicTask; use crate::utc_now; use std::time::Duration; -use tracing::{debug, error, info}; +use tracing::{debug, error}; const TASK_INTERVAL: Duration = Duration::from_secs(60); // Interval set to 1 minute const FIFTEEN_MINUTES: Duration = Duration::from_secs(45 * 60); @@ -50,11 +50,6 @@ impl RustMailTask for OAuth2RefreshTask { "Failed to refresh access token for {}: {}", token.account_id, error ); - } else { - info!( - "Successfully refreshed access token for {}", - token.account_id - ); } }); } diff --git a/src/modules/oauth2/token.rs b/src/modules/oauth2/token.rs index 71f33ce..bf894ca 100644 --- a/src/modules/oauth2/token.rs +++ b/src/modules/oauth2/token.rs @@ -148,16 +148,6 @@ impl OAuth2AccessToken { }).await } - pub async fn delete_by_oauth2_id(oauth2_id: u64) -> RustMailerResult<()> { - delete_impl(DB_MANAGER.meta_db(), move |rw|{ - rw.get().secondary::(OAuth2AccessTokenKey::oauth2_id, oauth2_id) - .map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))? - .ok_or_else(|| raise_error!(format!( - "The oauth2 access token entity with oauth2_id={oauth2_id} that you want to delete was not found." - ),ErrorCode::ResourceNotFound)) - }).await - } - pub async fn set_access_token( account_id: u64, access_token: String, diff --git a/src/modules/rest/public/login.rs b/src/modules/rest/public/login.rs index 32c32eb..9f0e3f2 100644 --- a/src/modules/rest/public/login.rs +++ b/src/modules/rest/public/login.rs @@ -12,7 +12,7 @@ use tracing::error; /// on successful authentication. #[handler] pub async fn login(password: String) -> Response { - match check_root_password(&password) { + match check_root_password(&password).await { Ok(root_token) => Response::builder() .status(http::StatusCode::OK) .content_type("text/plain") diff --git a/src/modules/settings/cli.rs b/src/modules/settings/cli.rs index a087665..e425160 100644 --- a/src/modules/settings/cli.rs +++ b/src/modules/settings/cli.rs @@ -217,6 +217,20 @@ pub struct Settings { )] pub rustmailer_envelope_cache_size: Option, + /// Abort the process when the tokio runtime stops producing heartbeats. + /// + /// A watchdog thread exits with a non-zero code when no runtime heartbeat is + /// observed for this many seconds, letting orchestrators (Docker restart + /// policies, supervisors) recover the process from a wedged runtime. + /// Set to 0 to disable the watchdog. + #[clap( + long, + env, + default_value = "60", + help = "Abort the process if the runtime heartbeat is stale for this many seconds (0 disables the watchdog)" + )] + pub rustmailer_watchdog_timeout_secs: u64, + /// Enables or disables the access token mechanism for HTTP endpoints. /// /// When set to `true`, HTTP requests will be subject to access token validation. diff --git a/src/modules/settings/system.rs b/src/modules/settings/system.rs index 2815d3f..0651efc 100644 --- a/src/modules/settings/system.rs +++ b/src/modules/settings/system.rs @@ -3,7 +3,7 @@ // Unauthorized copying, modification, or distribution is prohibited. use crate::modules::database::manager::DB_MANAGER; -use crate::modules::database::{find_impl, upsert_impl}; +use crate::modules::database::{async_find_impl, upsert_impl}; use crate::modules::error::RustMailerResult; use crate::utc_now; use native_db::*; @@ -35,16 +35,12 @@ impl SystemSetting { upsert_impl(DB_MANAGER.meta_db(), self).await } - pub fn get(key: &str) -> RustMailerResult> { - find_impl(DB_MANAGER.meta_db(), key) + pub async fn get(key: &str) -> RustMailerResult> { + async_find_impl(DB_MANAGER.meta_db(), key.to_string()).await } - // pub async fn list() -> RustMailerResult> { - // list_all_impl(DB_MANAGER.metadata_db()).await - // } - - pub fn get_existing_value(key: &str) -> RustMailerResult> { - let setting = Self::get(key)?; + pub async fn get_existing_value(key: &str) -> RustMailerResult> { + let setting = Self::get(key).await?; Ok(setting.map(|s| s.value)) } diff --git a/src/modules/token/root.rs b/src/modules/token/root.rs index bd025db..7ea0e9e 100644 --- a/src/modules/token/root.rs +++ b/src/modules/token/root.rs @@ -24,7 +24,7 @@ async fn get_or_generate( save_file_name: Option<&str>, force: bool, ) -> RustMailerResult { - if let Some(existing_value) = SystemSetting::get_existing_value(key)? { + if let Some(existing_value) = SystemSetting::get_existing_value(key).await? { if force { // If force is true, write the existing value to the file if let Some(filename) = save_file_name { @@ -77,8 +77,8 @@ async fn save_to_file(content: &str, filename: &str) -> RustMailerResult<()> { Ok(()) } -pub fn check_root_password(password: &str) -> RustMailerResult { - let stored_encrypted_password = SystemSetting::get_existing_value(ROOT_PASSWORD)?; +pub async fn check_root_password(password: &str) -> RustMailerResult { + let stored_encrypted_password = SystemSetting::get_existing_value(ROOT_PASSWORD).await?; let matched = match stored_encrypted_password { Some(ref stored) => { let decrypted = decrypt!(stored)?; @@ -94,12 +94,14 @@ pub fn check_root_password(password: &str) -> RustMailerResult { )); } - let root_token = SystemSetting::get_existing_value(ROOT_TOKEN)?.ok_or_else(|| { - raise_error!( - "Root token not found — this should never happen".into(), - ErrorCode::InternalError - ) - })?; + let root_token = SystemSetting::get_existing_value(ROOT_TOKEN) + .await? + .ok_or_else(|| { + raise_error!( + "Root token not found — this should never happen".into(), + ErrorCode::InternalError + ) + })?; Ok(root_token) } diff --git a/web/src/features/mailbox/components/mail-display-drawer.tsx b/web/src/features/mailbox/components/mail-display-drawer.tsx index f950eb9..c462550 100644 --- a/web/src/features/mailbox/components/mail-display-drawer.tsx +++ b/web/src/features/mailbox/components/mail-display-drawer.tsx @@ -132,11 +132,17 @@ export function MailDisplayDrawer({ open, setOpen, onOpenChange, currentEnvelope }, onError: (error) => { setLoading(false) + const isDisabled = (error as any)?.response?.status === 403; toast({ - title: 'Failed to load email message.', - description: `${error.message}`, + title: isDisabled ? 'Account is disabled' : 'Failed to load email message.', + description: isDisabled + ? 'This account is disabled. Message content is unavailable.' + : `${error.message}`, variant: 'destructive' }) + if (isDisabled) { + setOpen(null); + } }, }); diff --git a/web/src/features/mailbox/components/mail.tsx b/web/src/features/mailbox/components/mail.tsx index 1bb4fdf..5ceabd9 100644 --- a/web/src/features/mailbox/components/mail.tsx +++ b/web/src/features/mailbox/components/mail.tsx @@ -228,6 +228,9 @@ export function Mail({ } catch (error) { setIsError(true); setError(error); + // Stop retrying: clear the selected mailbox so dependency changes + // (page, remote, filter) do not re-trigger this effect in a loop. + setSelectedMailbox(undefined); } finally { setIsMessagesLoading(false) } @@ -346,10 +349,15 @@ export function Mail({ React.useEffect(() => { if (isError && error) { + const isDisabled = + error?.response?.status === 403 || + error?.message?.includes('disabled'); toast({ variant: "destructive", - title: "Failed to load messages", - description: error.message || "An unknown error occurred. Please try again.", + title: isDisabled ? "Account is disabled" : "Failed to load messages", + description: isDisabled + ? "This account is disabled. Only cached messages are available." + : (error.message || "An unknown error occurred. Please try again."), }); } }, [isError, error]); diff --git a/web/src/main.tsx b/web/src/main.tsx index c78ba8a..acfc903 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -41,7 +41,11 @@ const handleAxiosError = (error: any) => { } break; case 403: - router.navigate({ to: '/403' }); + toast({ + variant: 'destructive', + title: 'Access Denied', + description: 'You do not have permission to perform this action.', + }); break; case 500: toast({