diff --git a/crates/core/src/ext/event_bus.rs b/crates/core/src/ext/event_bus.rs index 637d499..08c51d7 100644 --- a/crates/core/src/ext/event_bus.rs +++ b/crates/core/src/ext/event_bus.rs @@ -26,7 +26,13 @@ // It never reads from the event bus — events are fire-and-forget. use std::net::IpAddr; -use std::sync::{LazyLock, RwLock}; +use std::sync::{LazyLock, Mutex, RwLock}; +use std::time::{Duration, Instant}; + +/// Free-form JSON value carried by events that need a content snapshot +/// (e.g. the subject / attachment names of a message being deleted, so the +/// audit trail stays self-describing after the content is gone). +pub type EventPayload = serde_json::Map; #[derive(Debug, Clone)] pub enum Event { @@ -34,10 +40,58 @@ pub enum Event { email_id: String, user: String, ip: IpAddr, + account_id: u64, + mailbox_id: u64, + /// Subject of the viewed message, so the audit trail is readable + /// without resolving the envelope again. + subject: Option, }, EmailDeleted { email_id: String, user: String, + account_id: u64, + mailbox_id: u64, + /// Subject at deletion time (the message is gone afterwards). + subject: Option, + /// Snapshot of the deleted message (attachment names, from/to, ...). + snapshot: Option, + }, + /// Raw EML file downloaded (export). + EmailExported { + email_id: String, + user: String, + account_id: u64, + subject: Option, + }, + /// Email restored back to the source IMAP server. + EmailRestored { + email_id: String, + user: String, + account_id: u64, + subject: Option, + }, + /// Facet tags added/removed on emails. + EmailTagged { + user: String, + account_id: u64, + /// Number of emails whose tags were changed. + count: u64, + }, + /// Facet tags added/removed on attachments. + AttachmentTagged { + user: String, + account_id: u64, + /// Number of attachments whose tags were changed. + count: u64, + }, + /// Attachment content streamed for in-browser preview (not a download). + AttachmentPreviewed { + email_id: String, + content_hash: String, + user: String, + account_id: u64, + mailbox_id: u64, + filename: Option, }, UserLoggedIn { user: String, @@ -47,6 +101,26 @@ pub enum Event { created_by: String, new_user: String, }, + UserUpdated { + updated_by: String, + target_user: String, + }, + UserRemoved { + removed_by: String, + target_user: String, + }, + RoleCreated { + created_by: String, + role_name: String, + }, + RoleUpdated { + updated_by: String, + role_name: String, + }, + RoleRemoved { + removed_by: String, + role_name: String, + }, SearchPerformed { query: String, user: String, @@ -57,8 +131,115 @@ pub enum Event { }, AttachmentDownloaded { email_id: String, + /// The attachment's own content hash. content_hash: String, user: String, + account_id: u64, + mailbox_id: u64, + filename: Option, + size: Option, + ext: Option, + /// Content hash of the parent email (EML), when known. + parent_content_hash: Option, + }, + AccountCreated { + created_by: String, + account_id: u64, + email: String, + }, + AccountUpdated { + updated_by: String, + account_id: u64, + email: String, + }, + AccountRemoved { + removed_by: String, + account_id: u64, + email: String, + }, + AccountDownloadStarted { + user: String, + account_id: u64, + run_gap_fill: bool, + }, + AccountDownloadStopped { + user: String, + account_id: u64, + }, + /// Batch role / access assignment on one or more accounts. + AccountRoleAssigned { + user: String, + target_user: String, + account_count: usize, + roles: Vec, + }, + AccessTokenCreated { + user: String, + target_user: String, + name: Option, + }, + AccessTokenRemoved { + user: String, + token_user: String, + name: Option, + }, + OAuth2ConfigCreated { + user: String, + oauth2_id: u64, + name: String, + }, + OAuth2ConfigUpdated { + user: String, + oauth2_id: u64, + name: String, + }, + OAuth2ConfigRemoved { + user: String, + oauth2_id: u64, + name: String, + }, + /// External OAuth2 token stored / refreshed for an account. + OAuth2TokenStored { + user: String, + account_id: u64, + }, + ImportPerformed { + user: String, + account_id: u64, + format: String, + total: u64, + success: u64, + failed: u64, + }, + MailboxRemoved { + user: String, + account_id: u64, + mailbox_id: u64, + }, + ProxyCreated { + user: String, + url: String, + }, + ProxyUpdated { + user: String, + url: String, + }, + ProxyRemoved { + user: String, + url: String, + }, + /// Pro edition: SSO (OIDC) login, logout, or license upload. + SsoLogin { + user: String, + ip: Option, + }, + SsoLogout { + user: String, + }, + LicenseUploaded { + user: String, + email: String, + edition: String, }, } @@ -75,6 +256,53 @@ impl EventBus for NoopEventBus { static EVENT_BUS: LazyLock>> = LazyLock::new(|| RwLock::new(Box::new(NoopEventBus))); +/// Short-window dedup of view/download events. +/// +/// The web UI can fire duplicate `message-content` requests for the same +/// email (React StrictMode double-effects, remote-content toggle, thread +/// expansion). Deduping here keeps the audit trail to one record per +/// intentional view without hiding repeated deliberate accesses. +static VIEW_DEDUP: LazyLock>> = + LazyLock::new(|| Mutex::new(Vec::new())); + +const VIEW_DEDUP_WINDOW: Duration = Duration::from_secs(10); + +fn is_duplicate_view(event: &Event) -> bool { + let key = match event { + Event::EmailViewed { + user, email_id, .. + } => Some(format!("email.viewed|{user}|{email_id}")), + Event::EmailDeleted { + user, email_id, .. + } => Some(format!("email.deleted|{user}|{email_id}")), + Event::AttachmentDownloaded { + user, + email_id, + content_hash, + .. + } => Some(format!("attachment.downloaded|{user}|{email_id}|{content_hash}")), + Event::AttachmentPreviewed { + user, + email_id, + content_hash, + .. + } => Some(format!("attachment.previewed|{user}|{email_id}|{content_hash}")), + _ => None, + }; + let Some(key) = key else { + return false; + }; + + let mut entries = VIEW_DEDUP.lock().unwrap(); + let now = Instant::now(); + entries.retain(|(_, at)| now.duration_since(*at) < VIEW_DEDUP_WINDOW); + if entries.iter().any(|(k, _)| *k == key) { + return true; + } + entries.push((key, now)); + false +} + /// Called by Pro/Enterprise at startup to replace the noop default. pub fn set_event_bus(bus: Box) { *EVENT_BUS.write().unwrap() = bus; @@ -82,5 +310,8 @@ pub fn set_event_bus(bus: Box) { /// Fire-and-forget. Called by the server at key points. pub fn emit(event: Event) { + if is_duplicate_view(&event) { + return; + } EVENT_BUS.read().unwrap().emit(event); } diff --git a/crates/core/src/message/search.rs b/crates/core/src/message/search.rs index f7cdbcd..ed19455 100644 --- a/crates/core/src/message/search.rs +++ b/crates/core/src/message/search.rs @@ -169,6 +169,10 @@ pub struct AttachmentSearchRequest { desc: Option, } impl AttachmentSearchRequest { + pub fn filter(&self) -> &AttachmentSearchFilter { + &self.filter + } + pub fn validate(&self) -> BichonResult<()> { if self.page == 0 || self.page_size == 0 { return Err(raise_error!( diff --git a/crates/core/src/settings/cli.rs b/crates/core/src/settings/cli.rs index 97558bb..50ede80 100644 --- a/crates/core/src/settings/cli.rs +++ b/crates/core/src/settings/cli.rs @@ -402,6 +402,17 @@ pub struct Settings { help = "Maximum per-file size in MB for PST uploads via the web UI" )] pub bichon_web_pst_upload_limit_mb: u64, + + /// Audit log retention period in days (default: 90). Older audit records + /// are purged periodically by a background task. 0 disables the cleanup. + /// Pro edition only. + #[clap( + long, + default_value = "90", + env, + help = "Audit log retention period in days (0 disables cleanup). Pro edition only." + )] + pub bichon_audit_retention_days: u64, } impl Settings { diff --git a/crates/server/src/rest/api/access_token.rs b/crates/server/src/rest/api/access_token.rs index d8fd6fd..96814a1 100644 --- a/crates/server/src/rest/api/access_token.rs +++ b/crates/server/src/rest/api/access_token.rs @@ -19,6 +19,7 @@ use crate::common::auth::WrappedContext; use crate::rest::api::ApiTags; use crate::rest::ApiResult; +use bichon_core::ext::event_bus::{emit, Event}; use bichon_core::token::view::AccessTokenResp; use bichon_core::users::permissions::Permission; use bichon_core::{token::payload::AccessTokenCreateRequest, token::AccessTokenModel}; @@ -60,8 +61,18 @@ impl AccessTokenApi { if context.user.id != token.user_id { context.require_permission(None, Permission::TOKEN_MANAGE)?; } - - Ok(AccessTokenModel::delete(&token.token)?) + let token_name = token.name.clone(); + let token_user = token.user_id; + AccessTokenModel::delete(&token.token)?; + let target_username = bichon_core::users::UserModel::find(token_user)? + .map(|u| u.username) + .unwrap_or_else(|| format!("user-{token_user}")); + emit(Event::AccessTokenRemoved { + user: context.user.username.clone(), + token_user: target_username, + name: token_name, + }); + Ok(()) } /// Creates a new api token. @@ -81,8 +92,16 @@ impl AccessTokenApi { if target_user_id != current_user_id { context.require_permission(None, Permission::USER_MANAGE)?; } - + let token_name = payload.0.name.clone(); let token_string = AccessTokenModel::create_api_token(target_user_id, payload.0)?; + let target_username = bichon_core::users::UserModel::find(target_user_id)? + .map(|u| u.username) + .unwrap_or_else(|| format!("user-{target_user_id}")); + emit(Event::AccessTokenCreated { + user: context.user.username.clone(), + target_user: target_username, + name: token_name, + }); Ok(PlainText(token_string)) } } diff --git a/crates/server/src/rest/api/account.rs b/crates/server/src/rest/api/account.rs index abc3218..bb46d17 100644 --- a/crates/server/src/rest/api/account.rs +++ b/crates/server/src/rest/api/account.rs @@ -30,6 +30,7 @@ use bichon_core::account::view::AccountResp; use bichon_core::cache::imap::task::SYNC_TASKS; use bichon_core::common::paginated::{paginate_vec, DataPage}; use bichon_core::error::code::ErrorCode; +use bichon_core::ext::event_bus::{emit, Event}; use bichon_core::raise_error; use bichon_core::store::tantivy::envelope::ENVELOPE_MANAGER; use bichon_core::users::permissions::Permission; @@ -86,7 +87,15 @@ impl AccountApi { ) -> ApiResult<()> { let account_id = account_id.0; context.require_permission(Some(account_id), Permission::ACCOUNT_MANAGE)?; + let email = AccountModel::find(account_id)? + .map(|a| a.email) + .unwrap_or_else(|| format!("account-{account_id}")); AccountModel::delete(account_id).await?; + emit(Event::AccountRemoved { + removed_by: context.user.username.clone(), + account_id, + email, + }); Ok(()) } @@ -100,6 +109,11 @@ impl AccountApi { ) -> ApiResult> { context.require_permission(None, Permission::ACCOUNT_CREATE)?; let account = AccountModel::create_account(context.user.id, payload.0).await?; + emit(Event::AccountCreated { + created_by: context.user.username.clone(), + account_id: account.id, + email: account.email.clone(), + }); Ok(Json(account)) } @@ -119,7 +133,16 @@ impl AccountApi { ) -> ApiResult<()> { let account_id = account_id.0; context.require_permission(Some(account_id), Permission::ACCOUNT_MANAGE)?; - Ok(AccountModel::update(account_id, payload.0, true)?) + AccountModel::update(account_id, payload.0, true)?; + let email = AccountModel::find(account_id)? + .map(|a| a.email) + .unwrap_or_else(|| format!("account-{account_id}")); + emit(Event::AccountUpdated { + updated_by: context.user.username.clone(), + account_id, + email, + }); + Ok(()) } /// List accounts with optional pagination parameters @@ -255,6 +278,11 @@ impl AccountApi { } context.require_permission(Some(account_id), Permission::ACCOUNT_MANAGE)?; SYNC_TASKS.start_manual_task(account_id, body.run_gap_fill).await?; + emit(Event::AccountDownloadStarted { + user: context.user.username.clone(), + account_id, + run_gap_fill: body.run_gap_fill, + }); Ok(()) } @@ -288,6 +316,10 @@ impl AccountApi { ))?; } SYNC_TASKS.cancel_manual_task(account_id).await; + emit(Event::AccountDownloadStopped { + user: context.user.username.clone(), + account_id, + }); Ok(()) } @@ -343,8 +375,25 @@ impl AccountApi { req: Json, context: WrappedContext, ) -> ApiResult<()> { + let req = req.0; req.validate_existence()?; - req.0.do_assign(&context)?; + let role_name = bichon_core::users::role::UserRole::find(req.role_id)? + .map(|r| r.name) + .unwrap_or_else(|| format!("role-{}", req.role_id)); + let target_users: Vec = req + .user_ids + .iter() + .filter_map(|uid| UserModel::find(*uid).ok().flatten()) + .map(|u| u.username) + .collect(); + let account_count = req.account_ids.len(); + req.do_assign(&context)?; + emit(Event::AccountRoleAssigned { + user: context.user.username.clone(), + target_user: target_users.join(", "), + account_count, + roles: vec![role_name], + }); Ok(()) } } diff --git a/crates/server/src/rest/api/attachment.rs b/crates/server/src/rest/api/attachment.rs index dfac50e..04bab02 100644 --- a/crates/server/src/rest/api/attachment.rs +++ b/crates/server/src/rest/api/attachment.rs @@ -21,6 +21,7 @@ use crate::rest::api::ApiTags; use crate::rest::ApiResult; use bichon_core::common::paginated::DataPage; use bichon_core::error::code::ErrorCode; +use bichon_core::ext::event_bus::{emit, Event}; use bichon_core::message::attachment::AttachmentMetadata; use bichon_core::message::search::search_attachment_impl; use bichon_core::message::search::AttachmentSearchRequest; @@ -58,7 +59,22 @@ impl AttachmentApi { } else { Some(context.user.account_access_map.keys().cloned().collect()) }; - Ok(Json(search_attachment_impl(authorized_ids, payload.0)?)) + let search_text = payload + .0 + .filter() + .text + .clone() + .unwrap_or_default() + .trim() + .to_string(); + let result = search_attachment_impl(authorized_ids, payload.0)?; + if !search_text.is_empty() { + emit(Event::SearchPerformed { + query: search_text, + user: context.user.username.clone(), + }); + } + Ok(Json(result)) } /// Retrieves the attachment (metadata) of a specific message. @@ -131,6 +147,21 @@ impl AttachmentApi { context.require_permission(Some(*account_id), Permission::DATA_MANAGE)?; } + let total_updates: u64 = req.updates.values().map(|ids| ids.len() as u64).sum(); + if total_updates > 0 { + for account_id in req.updates.keys() { + emit(Event::AttachmentTagged { + user: context.user.username.clone(), + account_id: *account_id, + count: req + .updates + .get(account_id) + .map(|ids| ids.len() as u64) + .unwrap_or(0), + }); + } + } + ATTACHMENT_MANAGER.update_attachment_tags(req).await?; Ok(()) } diff --git a/crates/server/src/rest/api/import.rs b/crates/server/src/rest/api/import.rs index 5ab5358..100e415 100644 --- a/crates/server/src/rest/api/import.rs +++ b/crates/server/src/rest/api/import.rs @@ -24,6 +24,7 @@ use crate::rest::ApiResult; use bichon_core::account::migration::AccountModel; use bichon_core::database::manager::DB_MANAGER; use bichon_core::database::MemDbModel; +use bichon_core::ext::event_bus::{emit, Event}; use bichon_core::import::{ check_temp_disk_space, get_import_progress, process_uploaded_file, update_progress, BatchEmlRequest, BatchEmlResult, ImportEmls, ImportHistory, ImportProgress, ImportStatus, @@ -67,6 +68,14 @@ impl ImportApi { let folder = payload.0.mail_folder.clone(); context.require_permission(Some(account_id), Permission::DATA_IMPORT_BATCH)?; let result = ImportEmls::do_import(payload.0).await?; + emit(Event::ImportPerformed { + user: context.user.username.clone(), + account_id, + format: "eml".to_string(), + total: result.total as u64, + success: result.success as u64, + failed: result.failed as u64, + }); // Save import history let progress = ImportProgress { @@ -252,6 +261,14 @@ impl ImportApi { let id = import_id.clone(); let folder_clone = folder.clone(); let user_id = context.user.id; + emit(Event::ImportPerformed { + user: context.user.username.clone(), + account_id, + format: format_str.clone(), + total: 0, + success: 0, + failed: 0, + }); tokio::task::spawn_blocking(move || { process_uploaded_file(&id, &temp_path, &file_name, account_id, &folder_clone, user_id); }); diff --git a/crates/server/src/rest/api/mailbox.rs b/crates/server/src/rest/api/mailbox.rs index d0b1eb2..af7218c 100644 --- a/crates/server/src/rest/api/mailbox.rs +++ b/crates/server/src/rest/api/mailbox.rs @@ -19,6 +19,7 @@ use crate::common::auth::WrappedContext; use crate::rest::api::ApiTags; use crate::rest::ApiResult; +use bichon_core::ext::event_bus::{emit, Event}; use bichon_core::mailbox::delete::delete_mailbox_impl; use bichon_core::mailbox::list::{get_account_mailboxes, MailboxListResponse}; use bichon_core::users::permissions::Permission; @@ -80,6 +81,12 @@ impl MailBoxApi { let account_id = account_id.0; let mailbox_id = mailbox_id.0; context.require_permission(Some(account_id), Permission::DATA_DELETE)?; - Ok(delete_mailbox_impl(account_id, mailbox_id).await?) + delete_mailbox_impl(account_id, mailbox_id).await?; + emit(Event::MailboxRemoved { + user: context.user.username.clone(), + account_id, + mailbox_id, + }); + Ok(()) } } diff --git a/crates/server/src/rest/api/message.rs b/crates/server/src/rest/api/message.rs index e19aabc..7230d45 100644 --- a/crates/server/src/rest/api/message.rs +++ b/crates/server/src/rest/api/message.rs @@ -30,6 +30,7 @@ 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::ext::event_bus::{emit, Event, EventPayload}; use bichon_core::message::list::get_thread_messages; use bichon_core::message::search::{search_messages_impl, EmailSearchRequest}; use bichon_core::message::tags::TagCount; @@ -67,7 +68,23 @@ impl MessageApi { for account_id in request.keys() { context.require_permission(Some(*account_id), Permission::DATA_DELETE)?; } - Ok(delete_messages_impl(request).await?) + // Audit: capture the subject and a content snapshot BEFORE the + // messages are gone, so the audit trail stays self-describing. + let user = context.user.username.clone(); + let snapshots = audit_snapshots_for_deleted(&request); + let result = delete_messages_impl(request).await; + for (account_id, email_id, mailbox_id, subject, snapshot) in snapshots { + emit(Event::EmailDeleted { + email_id, + user: user.clone(), + account_id, + mailbox_id, + subject, + snapshot, + }); + } + result?; + Ok(()) } /// Searches messages across all mailboxes using various filter criteria. @@ -88,7 +105,23 @@ impl MessageApi { } else { Some(context.user.account_access_map.keys().cloned().collect()) }; - Ok(Json(search_messages_impl(authorized_ids, payload.0)?)) + let search_text = payload + .0 + .filter + .text + .clone() + .unwrap_or_default() + .trim() + .to_string(); + let user = context.user.username.clone(); + let result = search_messages_impl(authorized_ids, payload.0)?; + if !search_text.is_empty() { + emit(Event::SearchPerformed { + query: search_text, + user, + }); + } + Ok(Json(result)) } /// Retrieves all messages belonging to a specific thread. Requires `thread_id`, `page`, and `page_size` query parameters. @@ -141,11 +174,22 @@ impl MessageApi { let account_id = account_id.0; let block_remote = block_remote_content.0.unwrap_or(false); context.require_permission(Some(account_id), Permission::DATA_READ)?; - Ok(Json(retrieve_email_content( - account_id, - envelope_id.0, - block_remote, - )?)) + let envelope_id = envelope_id.0.trim().to_string(); + let envelope = ENVELOPE_MANAGER + .get_envelope_by_id(account_id, &envelope_id)? + .map(|ea| ea.envelope); + let content = retrieve_email_content(account_id, envelope_id.clone(), block_remote)?; + if let Some(ip) = context.ip_addr { + emit(Event::EmailViewed { + email_id: envelope_id.clone(), + user: context.user.username.clone(), + ip, + account_id, + mailbox_id: envelope.as_ref().map(|e| e.mailbox_id).unwrap_or(0), + subject: envelope.as_ref().map(|e| e.subject.clone()), + }); + } + Ok(Json(content)) } /// Retrieves the content of an email embedded as an attachment. @@ -225,6 +269,17 @@ impl MessageApi { AccountModel::check_account_exists(account_id)?; context.require_permission(Some(account_id), Permission::DATA_RAW_DOWNLOAD)?; let envelope_id = envelope_id.0; + let subject = ENVELOPE_MANAGER + .get_envelope_by_id(account_id, &envelope_id) + .ok() + .flatten() + .map(|ea| ea.envelope.subject.clone()); + emit(Event::EmailExported { + email_id: envelope_id.clone(), + user: context.user.username.clone(), + account_id, + subject, + }); let reader = get_reader(account_id, envelope_id.clone()).await?; let body = Body::from_async_read(reader); let attachment = Attachment::new(body) @@ -248,6 +303,19 @@ impl MessageApi { ) -> ApiResult<()> { let account_id = account_id.0; context.require_permission(Some(account_id), Permission::DATA_EXPORT_BATCH)?; + for eid in &payload.0.envelope_ids { + let subject = ENVELOPE_MANAGER + .get_envelope_by_id(account_id, eid) + .ok() + .flatten() + .map(|ea| ea.envelope.subject.clone()); + emit(Event::EmailRestored { + email_id: eid.clone(), + user: context.user.username.clone(), + account_id, + subject, + }); + } Ok(restore_emails(account_id, payload.0.envelope_ids).await?) } @@ -272,7 +340,19 @@ impl MessageApi { AccountModel::check_account_exists(account_id)?; context.require_permission(Some(account_id), Permission::DATA_READ)?; let content_hash = content_hash.0.trim(); - let reader = retrieve_attachment_content(account_id, envelope_id, content_hash)?; + let meta = attachment_meta_for_audit(account_id, &envelope_id, content_hash); + let reader = retrieve_attachment_content(account_id, envelope_id.clone(), content_hash)?; + emit(Event::AttachmentDownloaded { + email_id: envelope_id.clone(), + content_hash: content_hash.to_string(), + user: context.user.username.clone(), + account_id, + mailbox_id: meta.mailbox_id, + filename: meta.filename, + size: meta.size, + ext: meta.ext, + parent_content_hash: meta.parent_content_hash, + }); let body = Body::from_async_read(reader); let attachment = Attachment::new(body) .attachment_type(AttachmentType::Attachment) @@ -302,7 +382,16 @@ impl MessageApi { AccountModel::check_account_exists(account_id)?; context.require_permission(Some(account_id), Permission::DATA_READ)?; let content_hash = content_hash.0.trim(); - let reader = retrieve_attachment_content(account_id, envelope_id, content_hash)?; + let meta = attachment_meta_for_audit(account_id, &envelope_id, content_hash); + let reader = retrieve_attachment_content(account_id, envelope_id.clone(), content_hash)?; + emit(Event::AttachmentPreviewed { + email_id: envelope_id.clone(), + content_hash: content_hash.to_string(), + user: context.user.username.clone(), + account_id, + mailbox_id: meta.mailbox_id, + filename: meta.filename, + }); let body = Body::from_async_read(reader); Ok(Attachment::new(body).attachment_type(AttachmentType::Inline)) } @@ -330,12 +419,24 @@ impl MessageApi { context.require_permission(Some(account_id), Permission::DATA_READ)?; let content_hash = content_hash.0.trim(); let nested_content_hash = nested_content_hash.0.trim(); + let meta = attachment_meta_for_audit(account_id, &envelope_id, content_hash); let reader = retrieve_nested_attachment_content( account_id, - envelope_id, + envelope_id.clone(), content_hash, nested_content_hash, )?; + emit(Event::AttachmentDownloaded { + email_id: envelope_id.clone(), + content_hash: nested_content_hash.to_string(), + user: context.user.username.clone(), + account_id, + mailbox_id: meta.mailbox_id, + filename: None, + size: None, + ext: None, + parent_content_hash: meta.parent_content_hash, + }); let body = Body::from_async_read(reader); let attachment = Attachment::new(body) .attachment_type(AttachmentType::Attachment) @@ -375,6 +476,21 @@ impl MessageApi { context.require_permission(Some(*account_id), Permission::DATA_MANAGE)?; } + let total_updates: u64 = req.updates.values().map(|ids| ids.len() as u64).sum(); + if total_updates > 0 { + for account_id in req.updates.keys() { + emit(Event::EmailTagged { + user: context.user.username.clone(), + account_id: *account_id, + count: req + .updates + .get(account_id) + .map(|ids| ids.len() as u64) + .unwrap_or(0), + }); + } + } + ENVELOPE_MANAGER.update_envelope_tags(req).await?; Ok(()) } @@ -395,3 +511,95 @@ impl MessageApi { Ok(Json(ENVELOPE_MANAGER.get_all_contacts(authorized_ids)?)) } } + +/// Attachment metadata captured for the audit trail. +struct AttachmentAuditMeta { + mailbox_id: u64, + filename: Option, + size: Option, + ext: Option, + parent_content_hash: Option, +} + +impl Default for AttachmentAuditMeta { + fn default() -> Self { + Self { + mailbox_id: 0, + filename: None, + size: None, + ext: None, + parent_content_hash: None, + } + } +} + +/// Resolves attachment display metadata (name, size, extension) and the +/// parent envelope's mailbox/content hash, for the audit trail. Best-effort: +/// failures degrade to defaults rather than failing the download. +fn attachment_meta_for_audit( + account_id: u64, + envelope_id: &str, + content_hash: &str, +) -> AttachmentAuditMeta { + let mut meta = AttachmentAuditMeta::default(); + if let Ok(Some(ea)) = ENVELOPE_MANAGER.get_envelope_by_id(account_id, envelope_id) { + meta.mailbox_id = ea.envelope.mailbox_id; + meta.parent_content_hash = Some(ea.envelope.content_hash); + if let Some(atts) = ea.attachments { + for att in atts { + if att.content_hash == content_hash { + meta.filename = att.filename.clone(); + meta.size = Some(att.size as u64); + meta.ext = att + .filename + .as_ref() + .and_then(|n| std::path::Path::new(n).extension()) + .and_then(|e| e.to_str()) + .map(|s| s.to_ascii_lowercase()); + break; + } + } + } + } + meta +} + +/// Collects (account_id, email_id, mailbox_id, subject, snapshot) for every +/// message about to be deleted, so the audit trail keeps a readable record +/// of what was removed. +fn audit_snapshots_for_deleted( + request: &HashMap>, +) -> Vec<(u64, String, u64, Option, Option)> { + let mut out = Vec::new(); + for (account_id, envelope_ids) in request { + for eid in envelope_ids { + let mut mailbox_id = 0u64; + let mut subject = None; + let mut snapshot: EventPayload = serde_json::Map::new(); + if let Ok(Some(ea)) = ENVELOPE_MANAGER.get_envelope_by_id(*account_id, eid) { + let e = ea.envelope; + mailbox_id = e.mailbox_id; + subject = Some(e.subject.clone()); + snapshot.insert("from".into(), serde_json::json!(e.from)); + snapshot.insert("date".into(), serde_json::json!(e.date)); + snapshot.insert("size".into(), serde_json::json!(e.size)); + snapshot.insert( + "attachment_count".into(), + serde_json::json!(e.regular_attachment_count), + ); + if let Some(atts) = ea.attachments { + let names: Vec = atts + .iter() + .filter_map(|a| a.filename.clone()) + .collect(); + if !names.is_empty() { + snapshot.insert("attachment_names".into(), serde_json::json!(names)); + } + } + snapshot.insert("content_hash".into(), serde_json::json!(e.content_hash)); + } + out.push((*account_id, eid.clone(), mailbox_id, subject, Some(snapshot))); + } + } + out +} diff --git a/crates/server/src/rest/api/oauth2.rs b/crates/server/src/rest/api/oauth2.rs index 55c37f6..26cfbd9 100644 --- a/crates/server/src/rest/api/oauth2.rs +++ b/crates/server/src/rest/api/oauth2.rs @@ -23,6 +23,7 @@ use bichon_core::error::code::ErrorCode; use crate::common::auth::WrappedContext; use crate::rest::api::ApiTags; use crate::rest::ApiResult; +use bichon_core::ext::event_bus::{emit, Event}; use bichon_core::oauth2::entity::{OAuth2, OAuth2CreateRequest, OAuth2UpdateRequest}; use bichon_core::oauth2::flow::{AuthorizeUrlRequest, OAuth2Flow}; use bichon_core::oauth2::token::{ExternalOAuth2Request, OAuth2AccessToken}; @@ -81,7 +82,17 @@ impl OAuth2Api { context: WrappedContext, ) -> ApiResult<()> { context.require_permission(None, Permission::ROOT)?; - Ok(OAuth2::delete(id.0)?) + let id = id.0; + let name = OAuth2::get(id)? + .map(|o| o.description.unwrap_or_default()) + .unwrap_or_else(|| format!("oauth2-{id}")); + OAuth2::delete(id)?; + emit(Event::OAuth2ConfigRemoved { + user: context.user.username.clone(), + oauth2_id: id, + name, + }); + Ok(()) } /// Creates a new OAuth2 configuration. @@ -100,8 +111,16 @@ impl OAuth2Api { context: WrappedContext, ) -> ApiResult<()> { context.require_permission(None, Permission::ROOT)?; + let name = request.0.description.clone().unwrap_or_default(); let entity = OAuth2::new(request.0)?; - Ok(entity.save()?) + let id = entity.id; + entity.save()?; + emit(Event::OAuth2ConfigCreated { + user: context.user.username.clone(), + oauth2_id: id, + name, + }); + Ok(()) } /// Updates an existing OAuth2 configuration. @@ -122,7 +141,17 @@ impl OAuth2Api { context: WrappedContext, ) -> ApiResult<()> { context.require_permission(None, Permission::ROOT)?; - Ok(OAuth2::update(id.0, payload.0)?) + let id = id.0; + let name = OAuth2::get(id)? + .map(|o| o.description.unwrap_or_default()) + .unwrap_or_else(|| format!("oauth2-{id}")); + OAuth2::update(id, payload.0)?; + emit(Event::OAuth2ConfigUpdated { + user: context.user.username.clone(), + oauth2_id: id, + name, + }); + Ok(()) } /// Lists OAuth2 configurations with pagination and sorting options. @@ -237,6 +266,10 @@ impl OAuth2Api { // Check account access permissions context.require_permission(Some(account_id), Permission::ACCOUNT_MANAGE)?; OAuth2AccessToken::upsert_external_oauth_token(account_id, request.0)?; + emit(Event::OAuth2TokenStored { + user: context.user.username.clone(), + account_id, + }); Ok(()) } } diff --git a/crates/server/src/rest/api/system.rs b/crates/server/src/rest/api/system.rs index 3205c7a..5739746 100644 --- a/crates/server/src/rest/api/system.rs +++ b/crates/server/src/rest/api/system.rs @@ -21,6 +21,7 @@ use crate::rest::api::ApiTags; use crate::rest::ApiResult; use bichon_core::dashboard::DashboardStats; use bichon_core::error::code::ErrorCode; +use bichon_core::ext::event_bus::{emit, Event}; use bichon_core::raise_error; use bichon_core::settings::cli::SETTINGS; use bichon_core::settings::proxy::{Proxy, ProxyTestResult}; @@ -88,7 +89,17 @@ impl SystemApi { context: WrappedContext, ) -> ApiResult<()> { context.require_permission(None, Permission::ROOT)?; - Ok(Proxy::delete(id.0)?) + let id = id.0; + let url = Proxy::get(id) + .ok() + .map(|p| p.url) + .unwrap_or_else(|| format!("proxy-{id}")); + Proxy::delete(id)?; + emit(Event::ProxyRemoved { + user: context.user.username.clone(), + url, + }); + Ok(()) } /// Retrieve a specific proxy configuration by ID. Requires root permission. @@ -118,8 +129,14 @@ impl SystemApi { #[oai(path = "/proxy", method = "post", operation_id = "create_proxy")] async fn create_proxy(&self, url: PlainText, context: WrappedContext) -> ApiResult<()> { context.require_permission(None, Permission::ROOT)?; - let entity = Proxy::new(url.0); - Ok(entity.save()?) + let url = url.0; + let entity = Proxy::new(url.clone()); + entity.save()?; + emit(Event::ProxyCreated { + user: context.user.username.clone(), + url, + }); + Ok(()) } /// Update the URL of a specific proxy by ID. Requires root permission. @@ -131,7 +148,14 @@ impl SystemApi { context: WrappedContext, ) -> ApiResult<()> { context.require_permission(None, Permission::ROOT)?; - Ok(Proxy::update(id.0, url.0)?) + let id = id.0; + let url = url.0; + Proxy::update(id, url.clone())?; + emit(Event::ProxyUpdated { + user: context.user.username.clone(), + url, + }); + Ok(()) } /// Get system configurations. diff --git a/crates/server/src/rest/api/users.rs b/crates/server/src/rest/api/users.rs index 6441ba3..93e00aa 100644 --- a/crates/server/src/rest/api/users.rs +++ b/crates/server/src/rest/api/users.rs @@ -21,6 +21,7 @@ use std::collections::BTreeMap; use crate::common::auth::WrappedContext; use crate::rest::api::ApiTags; use crate::rest::ApiResult; +use bichon_core::ext::event_bus::{emit, Event}; use bichon_core::token::AccessTokenModel; use bichon_core::users::minimal::MinimalUser; use bichon_core::users::payload::{ @@ -53,7 +54,17 @@ impl UsersApi { ) -> ApiResult<()> { let id = id.0; context.require_permission(None, Permission::USER_MANAGE)?; - Ok(UserRole::delete(id)?) + let role_name = UserRole::list_all()? + .into_iter() + .find(|r| r.id == id) + .map(|r| r.name) + .unwrap_or_else(|| format!("role-{id}")); + UserRole::delete(id)?; + emit(Event::RoleRemoved { + removed_by: context.user.username.clone(), + role_name, + }); + Ok(()) } /// Create a new account @@ -66,6 +77,10 @@ impl UsersApi { ) -> ApiResult> { context.require_permission(None, Permission::USER_MANAGE)?; let role = UserRole::create(payload.0)?; + emit(Event::RoleCreated { + created_by: context.user.username.clone(), + role_name: role.name.clone(), + }); Ok(Json(role)) } @@ -81,7 +96,17 @@ impl UsersApi { ) -> ApiResult<()> { let id = id.0; context.require_permission(None, Permission::USER_MANAGE)?; - Ok(UserRole::update(id, payload.0)?) + let role = UserRole::list_all()? + .into_iter() + .find(|r| r.id == id) + .map(|r| r.name) + .unwrap_or_else(|| format!("role-{id}")); + UserRole::update(id, payload.0)?; + emit(Event::RoleUpdated { + updated_by: context.user.username.clone(), + role_name: role, + }); + Ok(()) } #[oai(path = "/list-users", method = "get", operation_id = "list_users")] @@ -122,7 +147,15 @@ impl UsersApi { ) -> ApiResult<()> { let id = id.0; context.require_permission(None, Permission::USER_MANAGE)?; - Ok(UserModel::remove(id)?) + let target_username = UserModel::find(id)? + .map(|u| u.username) + .unwrap_or_else(|| format!("user-{id}")); + UserModel::remove(id)?; + emit(Event::UserRemoved { + removed_by: context.user.username.clone(), + target_user: target_username, + }); + Ok(()) } #[oai(path = "/users", method = "post", operation_id = "create_user")] @@ -133,6 +166,10 @@ impl UsersApi { ) -> ApiResult> { context.require_permission(None, Permission::USER_MANAGE)?; let user = UserModel::create(payload.0)?; + emit(Event::UserCreated { + created_by: context.user.username.clone(), + new_user: user.username.clone(), + }); let roles = UserRole::list_all()?; let role_lookup: BTreeMap = roles.into_iter().map(|r| (r.id, r)).collect(); Ok(Json(user.to_view(&role_lookup))) @@ -156,7 +193,15 @@ impl UsersApi { update_data.account_access_map = None; update_data.acl = None; } - Ok(UserModel::update(target_id, update_data)?) + UserModel::update(target_id, update_data)?; + let target_username = UserModel::find(target_id)? + .map(|u| u.username) + .unwrap_or_else(|| format!("user-{target_id}")); + emit(Event::UserUpdated { + updated_by: context.user.username.clone(), + target_user: target_username, + }); + Ok(()) } #[oai( diff --git a/crates/server/src/rest/public/login.rs b/crates/server/src/rest/public/login.rs index 8d9e411..1aafe41 100644 --- a/crates/server/src/rest/public/login.rs +++ b/crates/server/src/rest/public/login.rs @@ -16,8 +16,11 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . +use bichon_core::ext::event_bus::{emit, Event}; +use bichon_core::token::AccessTokenModel; use bichon_core::users::UserModel; -use poem::{handler, web::Json, IntoResponse, Response}; +use poem::web::{Json, RealIp}; +use poem::{handler, FromRequest, IntoResponse, Request, Response}; use serde::Deserialize; use tracing::error; @@ -32,20 +35,39 @@ pub struct LoginPayload { /// Accepts a plain text password and returns the `root_token` /// on successful authentication. #[handler] -pub fn login(payload: Json) -> Response { - let payload = payload.0; - match UserModel::authenticate_user(payload.username, payload.password) { - 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(), - }, +pub async fn login(payload: Json, req: &Request) -> Response { + let login_username = payload.0.username.clone(); + match UserModel::authenticate_user(payload.0.username, payload.0.password) { + Ok(result) => { + // Audit: record the successful login (user + client IP). + let username = result + .access_token + .as_deref() + .and_then(|t| AccessTokenModel::resolve_user_from_token(t).ok()) + .map(|u| u.username) + .unwrap_or(login_username); + let ip = RealIp::from_request_without_body(req) + .await + .ok() + .and_then(|r| r.0); + if let Some(ip) = ip { + emit(Event::UserLoggedIn { + user: username, + ip, + }); + } + 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() diff --git a/web/src/api/audit/api.ts b/web/src/api/audit/api.ts new file mode 100644 index 0000000..1a291de --- /dev/null +++ b/web/src/api/audit/api.ts @@ -0,0 +1,61 @@ +// +// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com) +// +// Audit log API client (Pro edition). +// + +import axiosInstance from '@/api/axiosInstance' + +export interface AuditRecord { + id: string + seq: number + ts_ms: number + event_type: string + user: string + account_id: number | null + mailbox_id: number | null + email_id: string | null + content_hash: string | null + ip: string | null + payload: Record + prev_hash: string | null +} + +export interface AuditPageResponse { + items: AuditRecord[] + total: number + page: number + page_size: number +} + +export interface AuditQueryParams { + page?: number + page_size?: number + start_ms?: number + end_ms?: number + user?: string + event_type?: string + account_id?: number + email_id?: string +} + +export async function list_audit_log( + params: AuditQueryParams, +): Promise { + const { data } = await axiosInstance.get('api/v1/audit-log', { + params, + }) + return data +} + +export async function list_audit_log_by_email( + envelopeId: string, + page: number, + page_size: number, +): Promise { + const { data } = await axiosInstance.get( + `api/v1/audit-log/email/${envelopeId}`, + { params: { page, page_size } }, + ) + return data +} diff --git a/web/src/components/date-picker.tsx b/web/src/components/date-picker.tsx index e1fd86f..b21f3b0 100644 --- a/web/src/components/date-picker.tsx +++ b/web/src/components/date-picker.tsx @@ -27,7 +27,7 @@ import { PopoverTrigger, } from '@/components/ui/popover' import i18n from '@/i18n' -import { dateFnsLocaleMap } from '@/lib/utils' +import { cn, dateFnsLocaleMap } from '@/lib/utils' import { enUS } from 'date-fns/locale' import { useEffect, useState } from 'react' @@ -35,12 +35,14 @@ type DatePickerProps = { selected: Date | undefined onSelect: (date: Date | undefined) => void placeholder?: string + className?: string } export function DatePicker({ selected, onSelect, placeholder = 'Pick a date', + className, }: DatePickerProps) { const currentLang = i18n.language.toLowerCase().replace('_', '-'); @@ -60,7 +62,11 @@ export function DatePicker({ + {open && ( +
+          {JSON.stringify(record.payload, null, 2)}
+        
+ )} + + ) +} + +export default function AuditLog() { + const { t } = useTranslation() + const { isPro } = useEdition() + const { require_any_permission } = useCurrentUser() + const [page, setPage] = useState(1) + const [userFilter, setUserFilter] = useState('all') + const [eventType, setEventType] = useState('all') + const [accountFilter, setAccountFilter] = useState('all') + const [startDate, setStartDate] = useState() + const [endDate, setEndDate] = useState() + const [applied, setApplied] = useState({ + user: 'all', + type: 'all', + account: 'all', + start: undefined as Date | undefined, + end: undefined as Date | undefined, + }) + + const { data: users, isLoading: isUsersLoading } = useQuery({ + queryKey: ['audit-log-users'], + queryFn: () => list_minimal_users(), + staleTime: 60_000, + }) + + const { data: accounts, isLoading: isAccountsLoading } = useQuery({ + queryKey: ['audit-log-accounts'], + queryFn: () => minimal_account_list(), + staleTime: 60_000, + }) + + const userOptions = [ + { value: 'all', label: t('audit.allTypes', 'All') }, + ...(users ?? []).map((u) => ({ + value: u.username, + label: `${u.username}${u.email ? ` · ${u.email}` : ''}`, + })), + ] + + const accountOptions = [ + { value: 'all', label: t('audit.allTypes', 'All') }, + ...(accounts ?? []).map((a) => ({ + value: String(a.id), + label: a.email, + })), + ] + + const { data, isLoading, isFetching } = useQuery({ + queryKey: ['audit-log', page, applied], + queryFn: () => + list_audit_log({ + page, + page_size: PAGE_SIZE, + user: applied.user === 'all' || !applied.user ? undefined : applied.user, + event_type: applied.type === 'all' || !applied.type ? undefined : applied.type, + account_id: applied.account === 'all' || !applied.account ? undefined : Number(applied.account), + start_ms: applied.start ? applied.start.getTime() : undefined, + end_ms: applied.end + ? new Date( + applied.end.getFullYear(), + applied.end.getMonth(), + applied.end.getDate() + 1, + ).getTime() + : undefined, + }), + placeholderData: (prev) => prev, + }) + + const applyFilters = () => { + setPage(1) + setApplied({ + user: userFilter, + type: eventType, + account: accountFilter, + start: startDate, + end: endDate, + }) + } + + const resetFilters = () => { + setUserFilter('all') + setEventType('all') + setAccountFilter('all') + setStartDate(undefined) + setEndDate(undefined) + setPage(1) + setApplied({ user: 'all', type: 'all', account: 'all', start: undefined, end: undefined }) + } + + const canView = isPro && require_any_permission(['system:root', 'user:manage', 'data:read:all']) + + if (!canView) { + return ( + <> + +
+
+ {t('audit.forbidden', 'Audit log is available in the Pro edition only.')} +
+
+ + ) + } + + return ( + <> + +
+
+

+ {t('audit.title', 'Audit Log')} +

+ + {/* Filters */} +
+
+ + setUserFilter(values[0])} + placeholder={t('audit.userPlaceholder', 'username')} + isLoading={isUsersLoading} + className='h-9 w-52 justify-start text-sm font-normal' + noItemsComponent={ + {t('audit.noUsers', 'No users found')} + } + /> +
+
+ + +
+
+ + setAccountFilter(values[0])} + placeholder={t('audit.accountPlaceholder', 'Select account')} + isLoading={isAccountsLoading} + height='240px' + className='h-9 w-52 justify-start text-sm font-normal' + noItemsComponent={ + {t('audit.noAccounts', 'No accounts found')} + } + /> +
+
+ + +
+
+ + +
+
+ + +
+
+ + {isLoading ? ( + + ) : ( + <> +
+ + + + {t('audit.time', 'Time')} + {t('audit.user', 'User')} + {t('audit.eventType', 'Event type')} + {t('audit.detail', 'Detail')} + + {t('audit.ip', 'IP')} + + + + + {data?.items?.map((rec) => ( + + + {formatTime(rec.ts_ms)} + + {rec.user} + + + {eventTypeLabel(t, rec.event_type)} + + + +
{describeEvent(rec) || '—'}
+ +
+ {rec.ip ?? '—'} +
+ ))} + {data?.items?.length === 0 && ( + + + {t('audit.empty', 'No audit events found')} + + + )} +
+
+
+
+ + {t('table.total', { total: data?.total ?? 0 })} + + (data?.total ?? 0) > page * PAGE_SIZE} + setPageIndex={(i) => setPage(i + 1)} + setPageSize={() => {}} + /> +
+ {isFetching && ( +
+ {t('audit.loading', 'Loading…')} +
+ )} + + )} +
+
+ + ) +} diff --git a/web/src/features/search/mail-message-view.tsx b/web/src/features/search/mail-message-view.tsx index 2239049..3772d1b 100644 --- a/web/src/features/search/mail-message-view.tsx +++ b/web/src/features/search/mail-message-view.tsx @@ -17,7 +17,7 @@ // along with this program. If not, see . -import { useEffect, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { useMutation } from '@tanstack/react-query'; import { Loader, Download, Trash2, MessageSquareMore, FileText, FileImage, FileVideo, FileArchive, FileSpreadsheet, FileCode, FileIcon, FileAudio, Upload, ShieldCheck, Pencil } from 'lucide-react'; @@ -177,7 +177,11 @@ export function MailMessageView({ setBlockRemote(true); }, [envelope.id]); + const loadedKeyRef = useRef(''); useEffect(() => { + const key = `${envelope.account_id}:${envelope.id}:${blockRemote}`; + if (loadedKeyRef.current === key) return; + loadedKeyRef.current = key; setLoading(true); loadMessageMutation.mutate(); }, [envelope.id, blockRemote]); diff --git a/web/src/locales/en.json b/web/src/locales/en.json index d05e6f0..47a9637 100644 --- a/web/src/locales/en.json +++ b/web/src/locales/en.json @@ -811,7 +811,8 @@ "oauth2": "OAuth2", "other": "Other", "settings": "Settings", - "users": "System Users" + "users": "System Users", + "auditLog": "Audit Log" }, "oauth2": { "aSecretKeyProvidedByTheOAuthProvider": "A secret key provided by the OAuth provider to authenticate your application.", @@ -1527,6 +1528,22 @@ "notSet": "Not set" } }, + "audit": { + "account": "Account ID", + "allTypes": "All", + "apply": "Apply", + "detail": "Detail", + "empty": "No audit events found", + "endDate": "End date", + "eventType": "Event type", + "loading": "Loading…", + "reset": "Reset", + "startDate": "Start date", + "time": "Time", + "title": "Audit Log", + "user": "User", + "userPlaceholder": "username" + }, "table": { "asc": "Asc", "delete": "Delete", diff --git a/web/src/routeTree.gen.ts b/web/src/routeTree.gen.ts index 55516b5..5ad0f4d 100644 --- a/web/src/routeTree.gen.ts +++ b/web/src/routeTree.gen.ts @@ -23,6 +23,9 @@ import { Route as AuthenticatedAttachmentIndexImport } from './routes/_authentic // Create Virtual Routes +const AuthenticatedAuditLogLazyImport = createFileRoute( + '/_authenticated/audit-log', +)() const errors503LazyImport = createFileRoute('/(errors)/503')() const errors500LazyImport = createFileRoute('/(errors)/500')() const errors404LazyImport = createFileRoute('/(errors)/404')() @@ -96,6 +99,14 @@ const AuthenticatedIndexRoute = AuthenticatedIndexImport.update({ getParentRoute: () => AuthenticatedRouteRoute, } as any) +const AuthenticatedAuditLogLazyRoute = AuthenticatedAuditLogLazyImport.update({ + id: '/audit-log', + path: '/audit-log', + getParentRoute: () => AuthenticatedRouteRoute, +} as any).lazy(() => + import('./routes/_authenticated/audit-log.lazy').then((d) => d.Route), +) + const errors503LazyRoute = errors503LazyImport .update({ id: '/(errors)/503', @@ -417,6 +428,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof errors503LazyImport parentRoute: typeof rootRoute } + '/_authenticated/audit-log': { + id: '/_authenticated/audit-log' + path: '/audit-log' + fullPath: '/audit-log' + preLoaderRoute: typeof AuthenticatedAuditLogLazyImport + parentRoute: typeof AuthenticatedRouteImport + } '/_authenticated/': { id: '/_authenticated/' path: '/' @@ -613,6 +631,7 @@ const AuthenticatedUsersRouteLazyRouteWithChildren = interface AuthenticatedRouteRouteChildren { AuthenticatedSettingsRouteLazyRoute: typeof AuthenticatedSettingsRouteLazyRouteWithChildren AuthenticatedUsersRouteLazyRoute: typeof AuthenticatedUsersRouteLazyRouteWithChildren + AuthenticatedAuditLogLazyRoute: typeof AuthenticatedAuditLogLazyRoute AuthenticatedIndexRoute: typeof AuthenticatedIndexRoute AuthenticatedAccountsNewLazyRoute: typeof AuthenticatedAccountsNewLazyRoute AuthenticatedAttachmentIndexRoute: typeof AuthenticatedAttachmentIndexRoute @@ -630,6 +649,7 @@ const AuthenticatedRouteRouteChildren: AuthenticatedRouteRouteChildren = { AuthenticatedSettingsRouteLazyRouteWithChildren, AuthenticatedUsersRouteLazyRoute: AuthenticatedUsersRouteLazyRouteWithChildren, + AuthenticatedAuditLogLazyRoute: AuthenticatedAuditLogLazyRoute, AuthenticatedIndexRoute: AuthenticatedIndexRoute, AuthenticatedAccountsNewLazyRoute: AuthenticatedAccountsNewLazyRoute, AuthenticatedAttachmentIndexRoute: AuthenticatedAttachmentIndexRoute, @@ -657,6 +677,7 @@ export interface FileRoutesByFullPath { '/403': typeof errors403LazyRoute '/404': typeof errors404LazyRoute '/503': typeof errors503LazyRoute + '/audit-log': typeof AuthenticatedAuditLogLazyRoute '/': typeof AuthenticatedIndexRoute '/accounts/new': typeof AuthenticatedAccountsNewLazyRoute '/settings/access': typeof AuthenticatedSettingsAccessLazyRoute @@ -686,6 +707,7 @@ export interface FileRoutesByTo { '/403': typeof errors403LazyRoute '/404': typeof errors404LazyRoute '/503': typeof errors503LazyRoute + '/audit-log': typeof AuthenticatedAuditLogLazyRoute '/': typeof AuthenticatedIndexRoute '/accounts/new': typeof AuthenticatedAccountsNewLazyRoute '/settings/access': typeof AuthenticatedSettingsAccessLazyRoute @@ -720,6 +742,7 @@ export interface FileRoutesById { '/(errors)/404': typeof errors404LazyRoute '/(errors)/500': typeof errors500LazyRoute '/(errors)/503': typeof errors503LazyRoute + '/_authenticated/audit-log': typeof AuthenticatedAuditLogLazyRoute '/_authenticated/': typeof AuthenticatedIndexRoute '/_authenticated/accounts/new': typeof AuthenticatedAccountsNewLazyRoute '/_authenticated/settings/access': typeof AuthenticatedSettingsAccessLazyRoute @@ -754,6 +777,7 @@ export interface FileRouteTypes { | '/403' | '/404' | '/503' + | '/audit-log' | '/' | '/accounts/new' | '/settings/access' @@ -782,6 +806,7 @@ export interface FileRouteTypes { | '/403' | '/404' | '/503' + | '/audit-log' | '/' | '/accounts/new' | '/settings/access' @@ -814,6 +839,7 @@ export interface FileRouteTypes { | '/(errors)/404' | '/(errors)/500' | '/(errors)/503' + | '/_authenticated/audit-log' | '/_authenticated/' | '/_authenticated/accounts/new' | '/_authenticated/settings/access' @@ -884,6 +910,7 @@ export const routeTree = rootRoute "children": [ "/_authenticated/settings", "/_authenticated/users", + "/_authenticated/audit-log", "/_authenticated/", "/_authenticated/accounts/new", "/_authenticated/attachment/", @@ -939,6 +966,10 @@ export const routeTree = rootRoute "/(errors)/503": { "filePath": "(errors)/503.lazy.tsx" }, + "/_authenticated/audit-log": { + "filePath": "_authenticated/audit-log.lazy.tsx", + "parent": "/_authenticated" + }, "/_authenticated/": { "filePath": "_authenticated/index.tsx", "parent": "/_authenticated" diff --git a/web/src/routes/_authenticated/audit-log.lazy.tsx b/web/src/routes/_authenticated/audit-log.lazy.tsx new file mode 100644 index 0000000..cbda2ad --- /dev/null +++ b/web/src/routes/_authenticated/audit-log.lazy.tsx @@ -0,0 +1,12 @@ +// +// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com) +// +// Audit log route (Pro edition). +// + +import { createLazyFileRoute } from '@tanstack/react-router' +import AuditLog from '@/features/audit-log' + +export const Route = createLazyFileRoute('/_authenticated/audit-log')({ + component: AuditLog, +})