diff --git a/protos/rustmailer.proto b/protos/rustmailer.proto index 9818912..647207c 100644 --- a/protos/rustmailer.proto +++ b/protos/rustmailer.proto @@ -297,6 +297,8 @@ enum MailerType { IMAP_SMTP = 0; // Use Gmail API GMAIL_API = 1; + // Use Graph API + GRAPH_API = 2; } // AccountService provides APIs for managing email accounts. diff --git a/src/modules/account/entity.rs b/src/modules/account/entity.rs index 5801412..0239cde 100644 --- a/src/modules/account/entity.rs +++ b/src/modules/account/entity.rs @@ -210,7 +210,8 @@ pub enum MailerType { /// Use IMAP/SMTP protocol #[default] ImapSmtp, - /// Use Gmail API GmailApi, + /// Use Graph API + GraphApi, } diff --git a/src/modules/account/migration.rs b/src/modules/account/migration.rs index 7343933..04a8797 100644 --- a/src/modules/account/migration.rs +++ b/src/modules/account/migration.rs @@ -24,10 +24,15 @@ use crate::{ address::AddressEntity, mailbox::MailBox, manager::FLAGS_STATE_MAP, migration::EmailEnvelopeV3, minimal::MinimalEnvelope, thread::EmailThread, }, - vendor::gmail::sync::{ - client::GmailClient, - envelope::GmailEnvelope, - labels::{GmailCheckPoint, GmailLabels}, + vendor::{ + gmail::sync::{ + client::GmailClient, + envelope::GmailEnvelope, + labels::{GmailCheckPoint, GmailLabels}, + }, + outlook::sync::{ + delta::FolderDeltaLink, envelope::OutlookEnvelope, folders::OutlookFolder, + }, }, }, database::{insert_impl, list_all_impl}, @@ -309,7 +314,7 @@ impl AccountV3 { ErrorCode::LicenseAccountLimitReached )); } - } + } } let entity = request.create_entity()?; entity.clone().save().await?; @@ -392,6 +397,11 @@ impl AccountV3 { GmailEnvelope::clean_account(account.id).await?; GmailCheckPoint::clean(account.id).await?; } + MailerType::GraphApi => { + OutlookFolder::clean(account_id).await?; + OutlookEnvelope::clean_account(account.id).await?; + FolderDeltaLink::clean(account.id).await?; + } } AddressEntity::clean_account(account.id).await?; EmailThread::clean_account(account.id).await?; diff --git a/src/modules/account/status.rs b/src/modules/account/status.rs index 06369c2..278bcdb 100644 --- a/src/modules/account/status.rs +++ b/src/modules/account/status.rs @@ -152,12 +152,12 @@ impl AccountRunningState { pub async fn set_initial_current_syncing_folder( account_id: u64, current_syncing_folder: String, - total_sync_batches: u32, + total_sync_batches: Option, ) -> RustMailerResult<()> { Self::update_account_running_state(account_id, move |current| { let mut updated = current.clone(); updated.current_syncing_folder = Some(current_syncing_folder); - updated.current_total_batches = Some(total_sync_batches); + updated.current_total_batches = total_sync_batches; Ok(updated) }) .await diff --git a/src/modules/cache/imap/address.rs b/src/modules/cache/imap/address.rs index a5a25a2..90d5fc4 100644 --- a/src/modules/cache/imap/address.rs +++ b/src/modules/cache/imap/address.rs @@ -352,7 +352,7 @@ impl AddressEntity { let from = envelope.from.as_ref().map(|f| f.address.clone()).flatten(); let envelope_hash = envelope.create_envelope_id(); let date = envelope.date.clone(); - let internal_date = Some(envelope.internal_date.clone()); + let internal_date = envelope.internal_date.clone(); let account_id = envelope.account_id; let mailbox_id = envelope.folder_id; let mut entities = Vec::new(); diff --git a/src/modules/cache/imap/mod.rs b/src/modules/cache/imap/mod.rs index 4508eb3..b8d4e92 100644 --- a/src/modules/cache/imap/mod.rs +++ b/src/modules/cache/imap/mod.rs @@ -11,13 +11,18 @@ use crate::{ imap::{ address::AddressEntity, envelope::EmailEnvelope, + migration::{EmailEnvelopeV2, EmailEnvelopeV3}, minimal::MinimalEnvelope, thread::EmailThread, - migration::{EmailEnvelopeV2, EmailEnvelopeV3}, }, - vendor::gmail::sync::{ - envelope::GmailEnvelope, - labels::{GmailCheckPoint, GmailLabels}, + vendor::{ + gmail::sync::{ + envelope::GmailEnvelope, + labels::{GmailCheckPoint, GmailLabels}, + }, + outlook::sync::{ + delta::FolderDeltaLink, envelope::OutlookEnvelope, folders::OutlookFolder, + }, }, }, database::ModelsAdapter, @@ -30,13 +35,13 @@ pub mod address; pub mod envelope; pub mod mailbox; pub mod manager; +pub mod migration; pub mod minimal; pub mod sync; pub mod task; #[cfg(test)] mod tests; pub mod thread; -pub mod migration; pub static ENVELOPE_MODELS: LazyLock = LazyLock::new(|| { let mut adapter = ModelsAdapter::new(); @@ -50,6 +55,9 @@ pub static ENVELOPE_MODELS: LazyLock = LazyLock::new(|| { adapter.register_model::(); adapter.register_model::(); adapter.register_model::(); + adapter.register_model::(); + adapter.register_model::(); + adapter.register_model::(); adapter.models }); diff --git a/src/modules/cache/imap/sync/flow.rs b/src/modules/cache/imap/sync/flow.rs index e2872ca..89be557 100644 --- a/src/modules/cache/imap/sync/flow.rs +++ b/src/modules/cache/imap/sync/flow.rs @@ -15,7 +15,9 @@ use crate::{ migration::EmailEnvelopeV3, minimal::MinimalEnvelope, sync::rebuild::{rebuild_mailbox_cache, rebuild_mailbox_cache_since_date}, - }, sync_type::SyncType, SEMAPHORE + }, + sync_type::SyncType, + SEMAPHORE, }, common::AddrVec, context::executors::RUST_MAIL_CONTEXT, @@ -91,7 +93,7 @@ pub async fn fetch_and_save_since_date( AccountRunningState::set_initial_current_syncing_folder( account_id, mailbox.name.clone(), - uid_batches.len() as u32, + Some(uid_batches.len() as u32), ) .await?; } @@ -177,7 +179,7 @@ pub async fn fetch_and_save_full_mailbox( AccountRunningState::set_initial_current_syncing_folder( account_id, mailbox.name.clone(), - total_batches, + Some(total_batches), ) .await?; } diff --git a/src/modules/cache/imap/task.rs b/src/modules/cache/imap/task.rs index f4750de..26dfbde 100644 --- a/src/modules/cache/imap/task.rs +++ b/src/modules/cache/imap/task.rs @@ -5,6 +5,7 @@ use crate::modules::account::entity::{AuthType, MailerType}; use crate::modules::cache::imap::sync::execute_imap_sync; use crate::modules::cache::vendor::gmail::sync::execute_gmail_sync; +use crate::modules::cache::vendor::outlook::sync::execute_outlook_sync; use crate::modules::oauth2::token::OAuth2AccessToken; use crate::modules::scheduler::periodic::TaskHandle; use crate::modules::{ @@ -98,6 +99,26 @@ impl AccountSyncTask { ) } } + MailerType::GraphApi => { + if OAuth2AccessToken::get(account.id).await?.is_none() { + if utc_now!() % 300_000 == 0 { + warn!("Account {}: Sync aborted. OAuth2 authorization not completed. Please visit the rustmailer admin page to authorize this account.", account_id); + } + return Ok(()); + } + if let Err(e) = execute_outlook_sync(&account).await { + STATUS_DISPATCHER + .append_error( + account_id, + format!("error in account sync task: {:#?}", e), + ) + .await; + error!( + "Failed to synchronize mailbox data for '{}': {:?}", + account_id, e + ) + } + } } } } diff --git a/src/modules/cache/model.rs b/src/modules/cache/model.rs index d4d27a2..50420b5 100644 --- a/src/modules/cache/model.rs +++ b/src/modules/cache/model.rs @@ -4,11 +4,18 @@ use poem_openapi::Object; use serde::{Deserialize, Serialize}; -use crate::{calculate_hash, id, modules::{ - cache::imap::{envelope::Received, mailbox::EnvelopeFlag, migration::EmailEnvelopeV3}, - common::Addr, - imap::section::{EmailBodyPart, ImapAttachment}, -}}; +use crate::{ + calculate_hash, id, + modules::{ + cache::imap::{ + envelope::Received, + mailbox::{EmailFlag, EnvelopeFlag}, + migration::EmailEnvelopeV3, + }, + common::Addr, + imap::section::{EmailBodyPart, ImapAttachment}, + }, +}; #[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize, Object)] pub struct Envelope { @@ -111,6 +118,8 @@ pub struct Envelope { /// /// **Note:** This field is populated only for Gmail API accounts. For other account types, it will be empty. pub labels: Vec, + + pub is_read: bool, } impl Envelope { @@ -134,6 +143,10 @@ impl From for Envelope { mailbox_name: value.mailbox_name, internal_date: value.internal_date, size: value.size, + is_read: value + .flags + .iter() + .any(|f| matches!(f.flag, EmailFlag::Seen)), flags: Some(value.flags), flags_hash: Some(value.flags_hash), bcc: value.bcc, diff --git a/src/modules/cache/sync_type.rs b/src/modules/cache/sync_type.rs index 236b68a..74e2ec4 100644 --- a/src/modules/cache/sync_type.rs +++ b/src/modules/cache/sync_type.rs @@ -4,7 +4,7 @@ use crate::{ modules::{ - account::{entity::MailerType, status::AccountRunningState, migration::AccountModel}, + account::{entity::MailerType, migration::AccountModel, status::AccountRunningState}, error::RustMailerResult, }, utc_now, @@ -51,7 +51,7 @@ pub async fn determine_sync_type(account: &AccountModel) -> RustMailerResult { + MailerType::GmailApi | MailerType::GraphApi => { if incremental_sync { AccountRunningState::set_incremental_sync_start(account.id).await?; SyncType::IncrementalSync diff --git a/src/modules/cache/vendor/gmail/sync/envelope.rs b/src/modules/cache/vendor/gmail/sync/envelope.rs index 5b81676..1aa5e53 100644 --- a/src/modules/cache/vendor/gmail/sync/envelope.rs +++ b/src/modules/cache/vendor/gmail/sync/envelope.rs @@ -8,8 +8,8 @@ use crate::{ cache::{ imap::{ address::AddressEntity, - thread::{EmailThread, EmailThreadKey}, migration::EmailEnvelopeV3, + thread::{EmailThread, EmailThreadKey}, }, model::Envelope, }, @@ -398,6 +398,8 @@ impl GmailEnvelope { } pub fn into_envelope(self, label_map: &AHashMap) -> Envelope { + let is_read = self.label_ids.iter().any(|f| f == "UNREAD"); + let labels: Vec = self .label_ids .into_iter() @@ -431,6 +433,7 @@ impl GmailEnvelope { attachments: None, body_meta: None, received: None, + is_read, labels, } } diff --git a/src/modules/cache/vendor/gmail/sync/flow.rs b/src/modules/cache/vendor/gmail/sync/flow.rs index 409b955..a98d360 100644 --- a/src/modules/cache/vendor/gmail/sync/flow.rs +++ b/src/modules/cache/vendor/gmail/sync/flow.rs @@ -76,7 +76,7 @@ pub async fn fetch_and_save_since_date( AccountRunningState::set_initial_current_syncing_folder( account_id, label.name.clone(), - total_batches, + Some(total_batches), ) .await?; } @@ -134,7 +134,7 @@ pub async fn fetch_and_save_since_date( history_ids.push(hid.to_string()); } GmailEnvelope::save_envelopes(envelopes).await?; - } + } // Break if API response has no next page if page_token.is_none() { break; @@ -175,7 +175,7 @@ pub async fn fetch_and_save_full_label( AccountRunningState::set_initial_current_syncing_folder( account_id, label.name.clone(), - total_batches, + Some(total_batches), ) .await?; } diff --git a/src/modules/cache/vendor/gmail/sync/mod.rs b/src/modules/cache/vendor/gmail/sync/mod.rs index 06721f3..c37359f 100644 --- a/src/modules/cache/vendor/gmail/sync/mod.rs +++ b/src/modules/cache/vendor/gmail/sync/mod.rs @@ -103,7 +103,7 @@ pub async fn execute_gmail_sync(account: &AccountModel) -> RustMailerResult<()> if !deleted_labels.is_empty() { info!( - "Account {}: Detected {} mailboxes missing from the IMAP server (not found in the LSUB response). \ + "Account {}: Detected {} mailboxes missing from the Gmail server (not found in the Gmail API response). \ Now cleaning up these mailboxes and their associated metadata locally.", account.id, deleted_labels.len() ); diff --git a/src/modules/cache/vendor/outlook/model.rs b/src/modules/cache/vendor/outlook/model.rs index 0294db9..8b9ba68 100644 --- a/src/modules/cache/vendor/outlook/model.rs +++ b/src/modules/cache/vendor/outlook/model.rs @@ -128,3 +128,35 @@ pub struct Attachment { #[serde(rename = "microsoft.graph.fileAttachment/contentId")] pub content_id: Option, } + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct DeltaResponse { + #[serde(rename = "@odata.context")] + pub context: Option, + + #[serde(rename = "@odata.nextLink")] + pub next_link: Option, + + #[serde(rename = "@odata.deltaLink")] + pub delta_link: Option, + + pub value: Option>, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct PartialMessage { + #[serde(rename = "@odata.etag")] + pub etag: Option, + + #[serde(rename = "@odata.type")] + pub odata_type: Option, + + pub id: String, + #[serde(rename = "@removed")] + pub removed: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct RemovedInfo { + pub reason: String, +} diff --git a/src/modules/cache/vendor/outlook/sync/client.rs b/src/modules/cache/vendor/outlook/sync/client.rs index 286a21f..32c00c2 100644 --- a/src/modules/cache/vendor/outlook/sync/client.rs +++ b/src/modules/cache/vendor/outlook/sync/client.rs @@ -1,6 +1,8 @@ use crate::{ modules::{ - cache::vendor::outlook::model::{MailFolder, MailFoldersResponse, MessageListResponse}, + cache::vendor::outlook::model::{ + MailFolder, MailFoldersResponse, Message, MessageListResponse, + }, error::{code::ErrorCode, RustMailerResult}, hook::http::HttpClient, oauth2::token::OAuth2AccessToken, @@ -12,7 +14,7 @@ use std::{future::Future, pin::Pin}; pub struct OutlookClient; impl OutlookClient { - async fn get_access_token(account_id: u64) -> RustMailerResult { + pub async fn get_access_token(account_id: u64) -> RustMailerResult { let record = OAuth2AccessToken::get(account_id).await?; record.and_then(|r| r.access_token).ok_or_else(|| { raise_error!( @@ -45,7 +47,7 @@ impl OutlookClient { prefix: &'a str, output: &'a mut Vec, access_token: &'a str, - ) -> Pin> + 'a>> { + ) -> Pin> + Send + 'a>> { Box::pin(async move { let mut url = match folder_id { Some(id) => { @@ -84,13 +86,15 @@ impl OutlookClient { }) } - async fn get_folder<'a>( - client: &'a HttpClient, - default_folder_name: &'a str, - access_token: &'a str, + pub async fn get_folder( + account_id: u64, + use_proxy: Option, + default_folder_name: &str, ) -> RustMailerResult { + let client = HttpClient::new(use_proxy).await?; + let access_token = Self::get_access_token(account_id).await?; let url = format!("https://graph.microsoft.com/v1.0/me/mailFolders/{default_folder_name}"); - let value = client.get(&url, access_token).await.map_err(|e| { + let value = client.get(&url, &access_token).await.map_err(|e| { raise_error!(format!("Request error: {e:#?}"), ErrorCode::InternalError) })?; let folder = serde_json::from_value::(value) @@ -109,16 +113,6 @@ impl OutlookClient { let access_token = Self::get_access_token(account_id).await?; let mut result = Vec::new(); Self::fetch_recursive(&client, None, "", &mut result, &access_token).await?; - let inbox = Self::get_folder(&client, "inbox", &access_token).await?; - let sentitems = Self::get_folder(&client, "sentitems", &access_token).await?; - for folder in &mut result { - if folder.id == inbox.id { - folder.display_name = "inbox".to_string(); - } - if folder.id == sentitems.id { - folder.display_name = "sentitems".to_string(); - } - } Ok(result) } @@ -171,12 +165,9 @@ impl OutlookClient { use_proxy: Option, folder_id: &str, ) -> RustMailerResult { - let mut url = format!("https://graph.microsoft.com/v1.0/me/mailFolders/{folder_id}/messages/delta?\ - $select=id,isRead,conversationId,internetMessageId,from,body,toRecipients,ccRecipients,\ - bccRecipients,replyTo,sender,subject,receivedDateTime,sentDateTime,isRead,bodyPreview,categories&\ - $expand=attachments($select=id,name,contentType,size,isInline,microsoft.graph.fileAttachment/contentId)&\ - $orderBy=receivedDateTime desc - "); + let mut url = format!( + "https://graph.microsoft.com/v1.0/me/mailFolders/{folder_id}/messages/delta?$select=id" + ); let client = HttpClient::new(use_proxy).await?; let access_token = Self::get_access_token(account_id).await?; loop { @@ -211,45 +202,28 @@ impl OutlookClient { } } - pub async fn list_delta( + pub async fn get_message( account_id: u64, use_proxy: Option, - delta_link: &str, - ) -> RustMailerResult { - let mut url = delta_link.to_string(); + id: &str, + ) -> RustMailerResult { + let url = format!("https://graph.microsoft.com/v1.0/me/messages/{id}?\ + $select=id,isRead,conversationId,internetMessageId,from,body,toRecipients,ccRecipients,\ + bccRecipients,replyTo,sender,subject,receivedDateTime,sentDateTime,isRead,bodyPreview,categories&\ + $expand=attachments($select=id,name,contentType,size,isInline,microsoft.graph.fileAttachment/contentId)"); + let client = HttpClient::new(use_proxy).await?; let access_token = Self::get_access_token(account_id).await?; - loop { - let value = client.get(url.as_str(), &access_token).await?; - if let Some(next_link) = value.get("@odata.nextLink") { - //处理这一页的delta数据 - url = next_link - .as_str() - .ok_or_else(|| { - raise_error!( - format!("unexpected type for @odata.nextLink in response at URL={url}"), - ErrorCode::InternalError - ) - })? - .to_string(); - } else if let Some(delta_link) = value.get("@odata.deltaLink") { - //delta处理完了拿到新的delta link,持久化 - return Ok(delta_link - .as_str() - .ok_or_else(|| { - raise_error!( - format!( - "unexpected type for @odata.deltaLink in response at URL={url}" - ), - ErrorCode::InternalError - ) - })? - .to_string()); - } else { - return Err(raise_error!(format!( - "neither @odata.nextLink nor @odata.deltaLink found in Graph API response at URL={url}" - ), ErrorCode::InternalError)); - } - } + let value = client.get(url.as_str(), &access_token).await?; + let message = serde_json::from_value::(value).map_err(|e| { + raise_error!( + format!( + "Failed to deserialize Graph API response into MessageListResponse: {:#?}. Possible model mismatch or API change.", + e + ), + ErrorCode::InternalError + ) + })?; + Ok(message) } } diff --git a/src/modules/cache/vendor/outlook/sync/delta.rs b/src/modules/cache/vendor/outlook/sync/delta.rs index 4c7f764..a8d3f5d 100644 --- a/src/modules/cache/vendor/outlook/sync/delta.rs +++ b/src/modules/cache/vendor/outlook/sync/delta.rs @@ -1,3 +1,4 @@ +use ahash::AHashSet; use itertools::Itertools; use native_db::*; use native_model::{native_model, Model}; @@ -6,11 +7,17 @@ use serde::{Deserialize, Serialize}; use crate::{ modules::{ + account::migration::AccountModel, + cache::vendor::outlook::{ + model::DeltaResponse, + sync::{client::OutlookClient, envelope::OutlookEnvelope, folders::OutlookFolder}, + }, database::{ async_find_impl, batch_delete_impl, delete_impl, filter_by_secondary_key_impl, manager::DB_MANAGER, upsert_impl, }, error::{code::ErrorCode, RustMailerResult}, + hook::http::HttpClient, utils::mailbox_id, }, raise_error, utc_now, @@ -92,3 +99,72 @@ impl FolderDeltaLink { Ok(()) } } + +pub async fn handle_delta( + account: &AccountModel, + local_folders: &[OutlookFolder], + remote_folders: &[OutlookFolder], +) -> RustMailerResult<()> { + let account_id = account.id; + let use_proxy = account.use_proxy.clone(); + let remote_folders = find_existing_remote_folders(local_folders, remote_folders); + for remote in remote_folders { + let mut url = FolderDeltaLink::get(account_id, &remote.folder_id) + .await? + .link; + let client = HttpClient::new(use_proxy).await?; + let access_token = OutlookClient::get_access_token(account_id).await?; + let mut batch = Vec::new(); + loop { + let value = client.get(url.as_str(), &access_token).await?; + let resp = serde_json::from_value::(value).map_err(|e| { + raise_error!( + format!( + "Failed to deserialize Graph API response into MessageListResponse: {:#?}. Possible model mismatch or API change.", + e + ), + ErrorCode::InternalError + ) + })?; + if let Some(items) = resp.value { + for item in items { + if item.removed.is_none() { + let message = + OutlookClient::get_message(account_id, use_proxy, &item.id).await?; + let mut envelope: OutlookEnvelope = message.try_into()?; + envelope.account_id = account_id; + envelope.folder_id = remote.id; + envelope.folder_name = remote.name.clone(); + batch.push(envelope); + } + } + } + if let Some(next_link) = resp.next_link { + url = next_link; + } else if let Some(delta_link) = resp.delta_link { + let new_delta_link = delta_link; + FolderDeltaLink::upsert(account_id, &remote.folder_id, &new_delta_link).await?; + break; + } else { + return Err(raise_error!(format!( + "neither @odata.nextLink nor @odata.deltaLink found in Graph API response at URL={url}" + ), ErrorCode::InternalError)); + } + } + OutlookEnvelope::save_envelopes(batch).await?; + OutlookFolder::upsert(remote).await?; + } + Ok(()) +} + +pub fn find_existing_remote_folders( + local_folders: &[OutlookFolder], + remote_folders: &[OutlookFolder], +) -> Vec { + let local_ids: AHashSet<_> = local_folders.iter().map(|l| &l.id).collect(); + remote_folders + .iter() + .filter(|remote| local_ids.contains(&remote.id)) + .cloned() + .collect() +} diff --git a/src/modules/cache/vendor/outlook/sync/envelope.rs b/src/modules/cache/vendor/outlook/sync/envelope.rs index a13a3f5..e8625f7 100644 --- a/src/modules/cache/vendor/outlook/sync/envelope.rs +++ b/src/modules/cache/vendor/outlook/sync/envelope.rs @@ -16,12 +16,16 @@ use crate::{ address::AddressEntity, thread::{EmailThread, EmailThreadKey}, }, + model::Envelope, vendor::outlook::model::{Message, Recipient}, }, common::Addr, - database::{batch_delete_impl, manager::DB_MANAGER, with_transaction}, + database::{ + batch_delete_impl, manager::DB_MANAGER, paginate_secondary_scan_impl, + secondary_find_impl, with_transaction, + }, error::{code::ErrorCode, RustMailerError, RustMailerResult}, - message::attachment, + rest::response::DataPage, utils::envelope_hash_from_id, }, raise_error, @@ -59,7 +63,7 @@ pub struct OutlookEnvelope { /// /// Corresponds to the Microsoft Graph API field `receivedDateTime`. /// May be `None` if the value is unavailable. - pub internal_date: i64, + pub internal_date: Option, /// The estimated size of the email in bytes. /// /// This is calculated locally as the sum of the email body length in bytes @@ -133,13 +137,15 @@ pub struct OutlookEnvelope { /// Each element is a string representing an Outlook category name. This field /// reflects the current categories assigned to the email in Outlook. pub categories: Vec, + + pub is_read: bool, } impl OutlookEnvelope { pub fn pk(&self) -> String { format!( "{}_{}", - self.internal_date, + self.internal_date.unwrap_or_default(), envelope_hash_from_id(self.account_id, self.folder_id, &self.id) ) } @@ -148,6 +154,34 @@ impl OutlookEnvelope { envelope_hash_from_id(self.account_id, self.folder_id, &self.id) } + pub async fn exists(&self) -> RustMailerResult { + let target = secondary_find_impl::( + DB_MANAGER.envelope_db(), + OutlookEnvelopeKey::create_envelope_id, + self.create_envelope_id(), + ) + .await?; + Ok(target.is_some()) + } + + pub async fn list_messages_in_folder( + folder_id: u64, + page: u64, + page_size: u64, + desc: bool, + ) -> RustMailerResult> { + paginate_secondary_scan_impl( + DB_MANAGER.envelope_db(), + Some(page), + Some(page_size), + Some(desc), + OutlookEnvelopeKey::folder_id, + folder_id, + ) + .await + .map(DataPage::from) + } + pub async fn clean_account(account_id: u64) -> RustMailerResult<()> { const BATCH_SIZE: usize = 200; let mut total_deleted = 0usize; @@ -182,6 +216,42 @@ impl OutlookEnvelope { Ok(()) } + pub async fn clean_folder_envelopes(account_id: u64, folder_id: u64) -> RustMailerResult<()> { + const BATCH_SIZE: usize = 200; + let mut total_deleted = 0usize; + let start_time = Instant::now(); + loop { + let deleted = batch_delete_impl(DB_MANAGER.envelope_db(), move |rw| { + let to_delete: Vec = rw + .scan() + .secondary(OutlookEnvelopeKey::folder_id) + .map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))? + .start_with(folder_id) + .map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))? + .filter_map(Result::ok) // filter only Ok values + .filter(|e: &OutlookEnvelope| e.account_id == account_id) + .take(BATCH_SIZE) + .collect(); + Ok(to_delete) + }) + .await?; + total_deleted += deleted; + // If this batch is empty, break the loop + if deleted == 0 { + break; + } + } + + info!( + "Finished deleting outlook envelopes for folder_id={} account_id={} total_deleted={} in {:?}", + folder_id, + account_id, + total_deleted, + start_time.elapsed() + ); + Ok(()) + } + pub async fn save_envelopes(envelopes: Vec) -> RustMailerResult<()> { with_transaction(DB_MANAGER.envelope_db(), move |rw| { for e in envelopes { @@ -206,7 +276,7 @@ impl OutlookEnvelope { envelope_id, e.account_id, e.folder_id, - Some(e.internal_date), + e.internal_date, e.date, ); // --- Thread upsert --- @@ -240,6 +310,10 @@ impl OutlookEnvelope { raise_error!(format!("{:#?}", err), ErrorCode::InternalError) })?; } + } else { + rw.upsert::(e.clone()).map_err(|err| { + raise_error!(format!("{:#?}", err), ErrorCode::InternalError) + })?; } } Ok(()) @@ -252,18 +326,19 @@ impl TryFrom for OutlookEnvelope { type Error = RustMailerError; fn try_from(msg: Message) -> Result { - fn parse_datetime(dt: &Option) -> RustMailerResult { - if let Some(s) = dt { - let parsed: DateTime = s.parse().map_err(|e| { - raise_error!( - format!("Invalid datetime {}: {}", s, e), - ErrorCode::InternalError - ) - })?; - Ok(parsed.timestamp_millis()) - } else { - Ok(0) - } + fn parse_datetime(dt: &Option) -> RustMailerResult> { + dt.as_ref() + .map(|s| { + s.parse::>() + .map(|dt| dt.timestamp_millis()) + .map_err(|e| { + raise_error!( + format!("Invalid datetime {}: {}", s, e), + ErrorCode::InternalError + ) + }) + }) + .transpose() } fn recipient_to_addr(r: &Option) -> Option { @@ -284,7 +359,7 @@ impl TryFrom for OutlookEnvelope { }) } let internal_date = parse_datetime(&msg.received_date_time)?; - let date = parse_datetime(&msg.sent_date_time).ok(); + let date = parse_datetime(&msg.sent_date_time)?; let body_len = msg .body .as_ref() @@ -312,11 +387,16 @@ impl TryFrom for OutlookEnvelope { size, bcc: recipients_to_addrs(&msg.bcc_recipients), cc: recipients_to_addrs(&msg.cc_recipients), - date: Some(date.unwrap_or(0)), + date: date, from: recipient_to_addr(&msg.from), - in_reply_to: msg.internet_message_id.clone(), + in_reply_to: None, sender: recipient_to_addr(&msg.sender), - message_id: msg.internet_message_id.clone(), + message_id: msg.internet_message_id.as_ref().map(|s| { + s.strip_prefix('<') + .and_then(|s| s.strip_suffix('>')) + .unwrap_or(s) + .to_string() + }), subject: msg.subject.clone(), thread_id, mime_version: None, @@ -326,6 +406,42 @@ impl TryFrom for OutlookEnvelope { snippet: msg.body_preview.clone(), conversation_id: msg.conversation_id.clone(), categories: msg.categories.clone().unwrap_or_default(), + is_read: msg.is_read.unwrap_or_default(), }) } } + +impl From for Envelope { + fn from(value: OutlookEnvelope) -> Self { + Self { + id: value.id, + account_id: value.account_id, + mailbox_id: value.folder_id, + mailbox_name: value.folder_name, + internal_date: value.internal_date, + size: value.size, + flags: None, + flags_hash: None, + bcc: value.bcc, + cc: value.cc, + date: value.date, + from: value.from, + in_reply_to: value.in_reply_to, + sender: value.sender, + return_address: None, + message_id: value.message_id, + subject: value.subject, + thread_name: None, + thread_id: value.thread_id, + mime_version: value.mime_version, + references: value.references, + reply_to: value.reply_to, + to: value.to, + attachments: None, + body_meta: None, + received: None, + labels: value.categories, + is_read: value.is_read, + } + } +} diff --git a/src/modules/cache/vendor/outlook/sync/flow.rs b/src/modules/cache/vendor/outlook/sync/flow.rs index 2b474e5..dc8cb3b 100644 --- a/src/modules/cache/vendor/outlook/sync/flow.rs +++ b/src/modules/cache/vendor/outlook/sync/flow.rs @@ -43,7 +43,7 @@ pub async fn fetch_and_save_since_date( AccountRunningState::set_initial_current_syncing_folder( account_id, folder.name.clone(), - 0, //无法知道到底多少条,因为graph api并不返回总数,只能一页页去获取 + None, ) .await?; } @@ -96,7 +96,7 @@ pub async fn fetch_and_save_full_folder( AccountRunningState::set_initial_current_syncing_folder( account_id, folder.name.clone(), - total_batches, + Some(total_batches), ) .await?; } diff --git a/src/modules/cache/vendor/outlook/sync/mod.rs b/src/modules/cache/vendor/outlook/sync/mod.rs index 9a7a8a8..5dd931d 100644 --- a/src/modules/cache/vendor/outlook/sync/mod.rs +++ b/src/modules/cache/vendor/outlook/sync/mod.rs @@ -1,15 +1,18 @@ +use std::time::Instant; + +use ahash::AHashSet; use tracing::info; use crate::modules::{ - account::{migration::AccountModel, status::AccountRunningState}, + account::{entity::MailerType, migration::AccountModel, status::AccountRunningState}, cache::{ imap::{address::AddressEntity, thread::EmailThread}, sync_type::{determine_sync_type, SyncType}, vendor::outlook::sync::{ - delta::FolderDeltaLink, + delta::{handle_delta, FolderDeltaLink}, envelope::OutlookEnvelope, folders::OutlookFolder, - rebuild::{rebuild_cache, rebuild_cache_since_date}, + rebuild::{rebuild_cache, rebuild_cache_since_date, rebuild_single_folder_cache}, sync_folders::get_sync_folders, }, }, @@ -30,11 +33,11 @@ pub mod folders; pub mod rebuild; pub mod sync_folders; pub async fn execute_outlook_sync(account: &AccountModel) -> RustMailerResult<()> { - // assert!( - // matches!(account.mailer_type, MailerType::GraphApi), - // "Bug: Unexpected mailer type, expected GraphApi, found: {:?}", - // account.mailer_type - // ); + assert!( + matches!(account.mailer_type, MailerType::GraphApi), + "Bug: Unexpected mailer type, expected GraphApi, found: {:?}", + account.mailer_type + ); let sync_type = determine_sync_type(account).await?; if matches!(sync_type, SyncType::SkipSync) { @@ -86,7 +89,34 @@ pub async fn execute_outlook_sync(account: &AccountModel) -> RustMailerResult<() } return Ok(()); } - todo!() + + handle_delta(account, &local_folders, &remote_folders).await?; + + let deleted_folders = find_deleted_labels(&local_folders, &remote_folders); + let missing_folders = find_missing_labels(&local_folders, &remote_folders); + if !deleted_folders.is_empty() { + info!( + "Account {}: Detected {} mailboxes missing from the Graph API server. \ + Now cleaning up these mailboxes and their associated metadata locally.", + account.id, + deleted_folders.len() + ); + cleanup_deleted_folders(account, &deleted_folders).await?; + } + + if !missing_folders.is_empty() { + info!( + count = missing_folders.len(), + labels = ?missing_folders, + "Inserting missing folders into database" + ); + OutlookFolder::batch_insert(&missing_folders).await?; + for folder in &missing_folders { + rebuild_single_folder_cache(account, folder).await?; + } + } + AccountRunningState::set_incremental_sync_end(account.id).await?; + Ok(()) } pub async fn should_rebuild_cache( @@ -117,3 +147,65 @@ pub async fn should_rebuild_cache( info!(account_id = account.id, "Cache cleaning completed"); Ok(true) } + +pub fn find_deleted_labels( + local_folders: &[OutlookFolder], + remote_folders: &[OutlookFolder], +) -> Vec { + let remote_ids: AHashSet<_> = remote_folders.iter().map(|l| &l.id).collect(); + + local_folders + .iter() + .filter(|l| !remote_ids.contains(&l.id)) + .cloned() + .collect() +} + +pub fn find_missing_labels( + local_folders: &[OutlookFolder], + remote_folders: &[OutlookFolder], +) -> Vec { + let local_ids: AHashSet<_> = local_folders.iter().map(|l| &l.id).collect(); + + remote_folders + .iter() + .filter(|l| !local_ids.contains(&l.id)) + .cloned() + .collect() +} + +async fn cleanup_deleted_folders( + account: &AccountModel, + deleted_folders: &[OutlookFolder], +) -> RustMailerResult<()> { + let start_time = Instant::now(); + for folder in deleted_folders { + OutlookEnvelope::clean_folder_envelopes(account.id, folder.id).await?; + AddressEntity::clean_mailbox_envelopes(account.id, folder.id).await?; + EmailThread::clean_mailbox_envelopes(account.id, folder.id).await?; + } + OutlookFolder::batch_delete(deleted_folders.to_vec()).await?; + let elapsed_time = start_time.elapsed().as_secs(); + info!( + "Cleanup deleted OutlookFolders completed: {} seconds elapsed.", + elapsed_time + ); + Ok(()) +} + +async fn cleanup_single_label( + account: &AccountModel, + folder: &OutlookFolder, +) -> RustMailerResult<()> { + let start_time = Instant::now(); + OutlookEnvelope::clean_folder_envelopes(account.id, folder.id).await?; + AddressEntity::clean_mailbox_envelopes(account.id, folder.id).await?; + EmailThread::clean_mailbox_envelopes(account.id, folder.id).await?; + OutlookFolder::delete(folder.id).await?; + let elapsed_time = start_time.elapsed().as_secs(); + info!( + "Cleanup OutlookFolders completed: {} seconds elapsed.", + elapsed_time + ); + Ok(()) +} diff --git a/src/modules/cache/vendor/outlook/sync/rebuild.rs b/src/modules/cache/vendor/outlook/sync/rebuild.rs index d6af232..ae3eb37 100644 --- a/src/modules/cache/vendor/outlook/sync/rebuild.rs +++ b/src/modules/cache/vendor/outlook/sync/rebuild.rs @@ -71,7 +71,7 @@ pub async fn rebuild_cache_since_date( ) -> RustMailerResult<()> { let start_time = Instant::now(); let mut total_inserted = 0; - let date = date_since.since_gmail_date()?; + let date = date_since.since_outlook_date()?; let account_id = account.id; let use_proxy = account.use_proxy; @@ -123,7 +123,7 @@ pub async fn rebuild_single_folder_cache( if folder.exists > 0 { match &account.date_since { Some(date_since) => { - let date = date_since.since_gmail_date()?; + let date = date_since.since_outlook_date()?; match fetch_and_save_since_date(account, date.as_str(), folder, true).await { Ok(inserted) => { info!( diff --git a/src/modules/cache/vendor/outlook/sync/sync_folders.rs b/src/modules/cache/vendor/outlook/sync/sync_folders.rs index 5a1f93f..e96dd36 100644 --- a/src/modules/cache/vendor/outlook/sync/sync_folders.rs +++ b/src/modules/cache/vendor/outlook/sync/sync_folders.rs @@ -19,7 +19,7 @@ use crate::{ pub async fn get_sync_folders(account: &AccountModel) -> RustMailerResult> { let all_mail_folders = OutlookClient::list_mailfolders(account.id, account.use_proxy).await?; debug!( - "Account {}: Retrieved {} visible labels from Gmail API: {:?}", + "Account {}: Retrieved {} visible folders from Graph API: {:?}", account.id, all_mail_folders.len(), all_mail_folders @@ -42,7 +42,7 @@ pub async fn get_sync_folders(account: &AccountModel) -> RustMailerResult RustMailerResult = if !subscribed.is_empty() { + let mut matched_folders: Vec = if !subscribed.is_empty() { all_mail_folders - .iter() + .clone() + .into_iter() .filter(|f| subscribed.contains(&f.id)) .collect() } else { @@ -77,13 +77,17 @@ pub async fn get_sync_folders(account: &AccountModel) -> RustMailerResult RustMailerResult for MailerType { match value { 0 => Ok(MailerType::ImapSmtp), 1 => Ok(MailerType::GmailApi), + 2 => Ok(MailerType::GraphApi), _ => Err("Invalid value for Unit"), } } @@ -363,6 +364,7 @@ impl From for i32 { match value { MailerType::ImapSmtp => 0, MailerType::GmailApi => 1, + MailerType::GraphApi => 2, } } } diff --git a/src/modules/mailbox/create.rs b/src/modules/mailbox/create.rs index 800d7d1..25cb220 100644 --- a/src/modules/mailbox/create.rs +++ b/src/modules/mailbox/create.rs @@ -91,5 +91,6 @@ pub async fn create_mailbox( MailerType::GmailApi => { GmailClient::create_label(account_id, account.use_proxy, request).await } + MailerType::GraphApi => todo!(), } } diff --git a/src/modules/mailbox/delete.rs b/src/modules/mailbox/delete.rs index 97d4a86..07e5e2a 100644 --- a/src/modules/mailbox/delete.rs +++ b/src/modules/mailbox/delete.rs @@ -35,5 +35,6 @@ pub async fn delete_mailbox(account_id: u64, mailbox_name: &str) -> RustMailerRe })?; GmailClient::delete_label(account_id, account.use_proxy, label_id).await } + MailerType::GraphApi => todo!(), } } diff --git a/src/modules/mailbox/list.rs b/src/modules/mailbox/list.rs index fb38a14..c71039b 100644 --- a/src/modules/mailbox/list.rs +++ b/src/modules/mailbox/list.rs @@ -10,6 +10,8 @@ use crate::modules::cache::imap::mailbox::{Attribute, AttributeEnum, MailBox}; use crate::modules::cache::vendor::gmail::model::labels::{Label, LabelDetail}; use crate::modules::cache::vendor::gmail::sync::client::GmailClient; use crate::modules::cache::vendor::gmail::sync::labels::GmailLabels; +use crate::modules::cache::vendor::outlook::sync::client::OutlookClient; +use crate::modules::cache::vendor::outlook::sync::folders::OutlookFolder; use crate::modules::context::executors::RUST_MAIL_CONTEXT; use crate::modules::error::code::ErrorCode; use crate::modules::error::{RustMailerError, RustMailerResult}; @@ -27,12 +29,26 @@ pub async fn get_account_mailboxes( 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, - (MailerType::GmailApi, true) => request_gmail_label_list(&account).await, (MailerType::GmailApi, false) => { let labels = GmailLabels::list_all(account_id).await?; Ok(labels.into_iter().map(Into::into).collect()) } + (MailerType::GraphApi, true) => { + let folders = OutlookClient::list_mailfolders(account_id, account.use_proxy).await?; + let mailboxes = folders + .into_iter() + .map(|f| { + let folder: OutlookFolder = f.try_into()?; + Ok(MailBox::from(folder)) + }) + .collect::>>()?; + Ok(mailboxes) + } + (MailerType::GraphApi, false) => { + let folders = OutlookFolder::list_all(account_id).await?; + Ok(folders.into_iter().map(Into::into).collect()) + } } } diff --git a/src/modules/mailbox/rename.rs b/src/modules/mailbox/rename.rs index 9518e95..5b0d30e 100644 --- a/src/modules/mailbox/rename.rs +++ b/src/modules/mailbox/rename.rs @@ -70,5 +70,6 @@ pub async fn update_mailbox( })?; GmailClient::update_label(account_id, account.use_proxy, label_id, &payload).await } + MailerType::GraphApi => todo!(), } } diff --git a/src/modules/message/append.rs b/src/modules/message/append.rs index 539ee06..0dc7ebd 100644 --- a/src/modules/message/append.rs +++ b/src/modules/message/append.rs @@ -108,6 +108,7 @@ impl AppendReplyToDraftRequest { self.append_reply_to_draft_gmail(&account, account_id) .await? } + MailerType::GraphApi => todo!(), } Ok(()) diff --git a/src/modules/message/attachment.rs b/src/modules/message/attachment.rs index 2695a89..d3d4c43 100644 --- a/src/modules/message/attachment.rs +++ b/src/modules/message/attachment.rs @@ -73,6 +73,7 @@ impl AttachmentRequest { )); } } + MailerType::GraphApi => todo!(), } Ok(()) } @@ -171,6 +172,7 @@ pub async fn retrieve_email_attachment( let reader = retrieve_gmail_attachment(&account, &request.id, &attachment_info).await?; Ok((reader, filename)) } + MailerType::GraphApi => todo!(), } } diff --git a/src/modules/message/content.rs b/src/modules/message/content.rs index 0b69c59..f97b390 100644 --- a/src/modules/message/content.rs +++ b/src/modules/message/content.rs @@ -98,6 +98,7 @@ impl MessageContentRequest { )); } } + MailerType::GraphApi => todo!(), } Ok(()) } @@ -423,6 +424,7 @@ pub async fn retrieve_email_content( retrieve_gmail_message_content(account_id, request.id, request.max_length, skip_cache) .await } + MailerType::GraphApi => todo!(), } } diff --git a/src/modules/message/delete.rs b/src/modules/message/delete.rs index d885e3a..df41ae2 100644 --- a/src/modules/message/delete.rs +++ b/src/modules/message/delete.rs @@ -66,6 +66,7 @@ pub async fn move_to_trash( move_to_trash_or_delete_messages_directly(account_id, &uids, mailbox).await } MailerType::GmailApi => gmail_move_to_trash(&account, &request.ids).await, + MailerType::GraphApi => todo!(), } } diff --git a/src/modules/message/full.rs b/src/modules/message/full.rs index b6cee51..7ccc845 100644 --- a/src/modules/message/full.rs +++ b/src/modules/message/full.rs @@ -58,6 +58,7 @@ pub async fn retrieve_raw_email( retrieve_imap_raw_email(account_id, mailbox, uid).await } MailerType::GmailApi => retrieve_gmail_raw_email(&account, id).await, + MailerType::GraphApi => todo!(), } } diff --git a/src/modules/message/list.rs b/src/modules/message/list.rs index 3855c24..050a984 100644 --- a/src/modules/message/list.rs +++ b/src/modules/message/list.rs @@ -9,8 +9,9 @@ use crate::{ cache::{ imap::{mailbox::MailBox, migration::EmailEnvelopeV3, thread::EmailThread}, model::Envelope, - vendor::gmail::sync::{ - client::GmailClient, envelope::GmailEnvelope, labels::GmailLabels, + vendor::{ + gmail::sync::{client::GmailClient, envelope::GmailEnvelope, labels::GmailLabels}, + outlook::sync::{envelope::OutlookEnvelope, folders::OutlookFolder}, }, }, common::{decode_page_token, parallel::run_with_limit}, @@ -188,6 +189,7 @@ async fn fetch_remote_messages( total_pages: Some(total_pages), }) } + MailerType::GraphApi => todo!(), } } @@ -256,7 +258,6 @@ async fn fetch_local_messages( )) } } - MailerType::GmailApi => { let target_label = GmailLabels::get_by_name(account.id, mailbox_name).await?; let DataPage { @@ -294,6 +295,43 @@ async fn fetch_local_messages( )) } } + MailerType::GraphApi => { + let target_label = OutlookFolder::get_by_name(account.id, mailbox_name).await?; + + let DataPage { + current_page: _, + page_size, + total_items, + items, + total_pages, + } = OutlookEnvelope::list_messages_in_folder(target_label.id, page, page_size, desc) + .await?; + + if total_items == 0 { + Ok(CursorDataPage::new(None, page_size, 0, None, vec![])) + } else { + let total_pages = total_pages.ok_or_else(|| { + raise_error!( + "Internal error: total_pages is None (this should never happen)".into(), + ErrorCode::InternalError + ) + })?; + + let next_page_token = if page == total_pages { + None + } else { + Some(base64_encode_url_safe!((page + 1).to_string())) + }; + + Ok(CursorDataPage::new( + next_page_token, + page_size, + total_items, + Some(total_pages), + items.into_iter().map(|e| e.into()).collect(), + )) + } + } } } @@ -341,6 +379,7 @@ pub async fn list_threads_in_mailbox( let label = GmailLabels::get_by_name(account_id, mailbox_name).await?; EmailThread::list_threads_in_label(account, label.id, page, page_size, desc).await } + MailerType::GraphApi => todo!(), } } @@ -371,5 +410,6 @@ pub async fn get_thread_messages( .map(|e| e.into_envelope(&map)) .collect()) } + MailerType::GraphApi => todo!(), } } diff --git a/src/modules/message/search/payload.rs b/src/modules/message/search/payload.rs index b7f6099..9f114c9 100644 --- a/src/modules/message/search/payload.rs +++ b/src/modules/message/search/payload.rs @@ -497,6 +497,7 @@ impl MessageSearchRequest { self.gmail_api_search_impl(&account, next_page_token, page_size) .await } + MailerType::GraphApi => todo!(), } } @@ -831,6 +832,7 @@ impl UnifiedSearchRequest { })?; envelope.into_envelope(&label_map) } + MailerType::GraphApi => todo!(), }; items.push(envelope); } diff --git a/src/modules/message/transfer.rs b/src/modules/message/transfer.rs index b0c8b2d..b5f4946 100644 --- a/src/modules/message/transfer.rs +++ b/src/modules/message/transfer.rs @@ -178,5 +178,6 @@ pub async fn transfer_messages( } } } + MailerType::GraphApi => todo!(), } } diff --git a/src/modules/smtp/request/forward.rs b/src/modules/smtp/request/forward.rs index ba00814..203642d 100644 --- a/src/modules/smtp/request/forward.rs +++ b/src/modules/smtp/request/forward.rs @@ -180,6 +180,7 @@ impl EmailBuilder for ForwardEmailRequest { EmailHandler::get_gmail_envelope(account, &self.mailbox_name, &self.id).await?; (envelope, None) } + MailerType::GraphApi => todo!(), }; let from = Address::new_address( account.name.as_ref().map(|n| Cow::Owned(n.to_string())), diff --git a/src/modules/smtp/request/reply.rs b/src/modules/smtp/request/reply.rs index 71a6fae..c49c4ae 100644 --- a/src/modules/smtp/request/reply.rs +++ b/src/modules/smtp/request/reply.rs @@ -157,6 +157,7 @@ impl EmailBuilder for ReplyEmailRequest { EmailHandler::get_gmail_envelope(account, &self.mailbox_name, &self.id).await?; (envelope, None) } + MailerType::GraphApi => todo!(), }; let from = Address::new_address( diff --git a/src/modules/smtp/request/task.rs b/src/modules/smtp/request/task.rs index a6cd31d..54388d4 100644 --- a/src/modules/smtp/request/task.rs +++ b/src/modules/smtp/request/task.rs @@ -322,6 +322,7 @@ impl Task for SmtpTask { } } } + MailerType::GraphApi => todo!(), } }) } diff --git a/web/src/features/accounts/components/data-table-row-actions.tsx b/web/src/features/accounts/components/data-table-row-actions.tsx index ed1fbc3..237e238 100644 --- a/web/src/features/accounts/components/data-table-row-actions.tsx +++ b/web/src/features/accounts/components/data-table-row-actions.tsx @@ -49,6 +49,9 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) { case MailerType.GmailApi: setOpen("gmail-api-edit"); break; + case MailerType.GraphApi: + setOpen("graph-api-edit"); + break; } }} > diff --git a/web/src/features/accounts/components/oauth2-action.tsx b/web/src/features/accounts/components/oauth2-action.tsx index b6b67f0..a0c5bd4 100644 --- a/web/src/features/accounts/components/oauth2-action.tsx +++ b/web/src/features/accounts/components/oauth2-action.tsx @@ -12,20 +12,31 @@ import { AccountEntity, MailerType } from '../data/schema' interface DataTableRowActionsProps { row: Row } - export function OAuth2Action({ row }: DataTableRowActionsProps) { const { setOpen, setCurrentRow } = useAccountContext() + const mailer = row.original - return ( - (row.original.mailer_type === MailerType.ImapSmtp && - row.original.imap?.auth.auth_type === 'OAuth2') || - row.original.mailer_type === MailerType.GmailApi - ) ? ( - - ) : ( - Password - ) + const isOAuth2 = + (mailer.mailer_type === MailerType.ImapSmtp && + mailer.imap?.auth.auth_type === "OAuth2") || + mailer.mailer_type === MailerType.GmailApi || + mailer.mailer_type === MailerType.GraphApi + + if (isOAuth2) { + return ( + + ) + } + + return Password } diff --git a/web/src/features/accounts/components/outlook-account-dialog.tsx b/web/src/features/accounts/components/outlook-account-dialog.tsx new file mode 100644 index 0000000..b116fe0 --- /dev/null +++ b/web/src/features/accounts/components/outlook-account-dialog.tsx @@ -0,0 +1,531 @@ +/* + * Copyright © 2025 rustmailer.com + * Licensed under RustMailer License Agreement v1.0 + * Unauthorized use or distribution is prohibited. + */ + +import { z } from 'zod'; +import { Button } from '@/components/ui/button'; +import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import { useToast } from '@/hooks/use-toast'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { ToastAction } from '@/components/ui/toast'; +import { AxiosError } from 'axios'; +import { AccountEntity, MailerType } from '../data/schema'; +import React, { useState } from 'react'; +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { create_account, update_account } from '@/api/account/api'; +import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'; +import { Input } from '@/components/ui/input'; +import { Checkbox } from '@/components/ui/checkbox'; +import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'; +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; +import { Calendar } from '@/components/ui/calendar'; +import { CalendarIcon, Loader2 } from 'lucide-react'; +import { format } from 'date-fns'; +import { cn } from '@/lib/utils'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import useProxyList from '@/hooks/use-proxy'; + + +const relativeDateSchema = z.object({ + unit: z.enum(["Days", "Months", "Years"], { message: "Please select a unit" }), + value: z.number({ message: 'Please enter a value' }).int().min(1, "Must be at least 1"), +}); + +const dateSelectionSchema = z.union([ + z.object({ fixed: z.string({ message: "Please select a date" }) },), + z.object({ relative: relativeDateSchema }), + z.undefined(), +]); + + +const accountSchema = () => + z.object({ + name: z.string().optional(), + email: z.string({ required_error: 'Email is required' }).email({ message: 'Invalid email address' }), + enabled: z.boolean(), + minimal_sync: z.boolean(), + use_proxy: z.number().optional(), + date_since: dateSelectionSchema.optional(), + folder_limit: z + .number({ invalid_type_error: 'Folder limit must be a number' }) + .int() + .min(100, { message: 'Folder limit must be at least 100' }) + .optional(), + incremental_sync_interval_sec: z.number({ invalid_type_error: 'Incremental sync interval must be a number' }).int().min(1, { message: 'Incremental sync interval must be at least 1 second' }), + }); + + +export type GraphApiAccount = { + name?: string; + email: string; + enabled: boolean; + minimal_sync: boolean; + date_since?: { + fixed?: string; + relative?: { + unit?: 'Days' | 'Months' | 'Years'; + value?: number; + }; + }; + folder_limit?: number, + use_proxy?: number, + incremental_sync_interval_sec: number; +}; + + + +interface Props { + currentRow?: AccountEntity; + open: boolean; + onOpenChange: (open: boolean) => void; +} + + +const defaultValues: GraphApiAccount = { + name: '', + email: '', + enabled: true, + date_since: undefined, + folder_limit: undefined, + incremental_sync_interval_sec: 30, + minimal_sync: false, + use_proxy: undefined +}; + + +const mapCurrentRowToFormValues = (currentRow: AccountEntity): GraphApiAccount => { + let account = { + name: currentRow.name === null ? '' : currentRow.name, + email: currentRow.email, + enabled: currentRow.enabled, + minimal_sync: currentRow.minimal_sync ?? false, + date_since: currentRow.date_since ?? undefined, + folder_limit: currentRow.folder_limit ?? undefined, + incremental_sync_interval_sec: currentRow.incremental_sync_interval_sec, + use_proxy: currentRow.use_proxy + }; + return account; +}; + + +export function GraphApiAccountDialog({ currentRow, open, onOpenChange }: Props) { + const isEdit = !!currentRow; + const { toast } = useToast(); + const [rangeType, setRangeType] = useState<'none' | 'fixed' | 'relative'>(currentRow?.date_since + ? currentRow.date_since.fixed + ? "fixed" + : currentRow.date_since.relative + ? "relative" + : "none" + : "none") + + const { proxyOptions } = useProxyList(); + + const form = useForm({ + mode: "all", + defaultValues: isEdit ? mapCurrentRowToFormValues(currentRow) : defaultValues, + resolver: zodResolver(accountSchema()), + }); + + const queryClient = useQueryClient(); + + const createMutation = useMutation({ + mutationFn: create_account, + onSuccess: handleSuccess, + onError: handleError, + }); + + const updateMutation = useMutation({ + mutationFn: (data: Record) => update_account(currentRow?.id!, data), + onSuccess: handleSuccess, + onError: handleError, + }); + + function handleSuccess() { + toast({ + title: `Account ${isEdit ? 'Updated' : 'Created'}`, + description: `Your account has been successfully ${isEdit ? 'updated' : 'created'}.`, + action: Close, + }); + + queryClient.invalidateQueries({ queryKey: ['account-list'] }); + form.reset(); + onOpenChange(false); + } + + function handleError(error: AxiosError) { + const errorMessage = + (error.response?.data as { message?: string })?.message || + error.message || + `${isEdit ? 'Update' : 'Creation'} failed, please try again later`; + + toast({ + variant: "destructive", + title: `Account ${isEdit ? 'Update' : 'Creation'} Failed`, + description: errorMessage as string, + action: Try again, + }); + console.error(error); + } + + const onSubmit = React.useCallback( + (data: GraphApiAccount) => { + const commonData = { + email: data.email, + name: data.name, + enabled: data.enabled, + date_since: data.date_since, + folder_limit: data.folder_limit, + minimal_sync: data.minimal_sync, + incremental_sync_interval_sec: data.incremental_sync_interval_sec, + use_proxy: data.use_proxy + }; + if (isEdit) { + updateMutation.mutate(commonData); + } else { + const payload = { + ...commonData, + mailer_type: MailerType.GraphApi + }; + createMutation.mutate(payload); + } + }, + [isEdit, updateMutation, createMutation] + ); + return ( + { + form.reset(); + onOpenChange(state); + }} + > + + + {isEdit ? "Update Account" : "Add Account"} + + {isEdit ? 'Update the email account here. ' : 'Add new email account here. '} + Click save when you're done. + + + +
+ + ( + + + Email Address: + + + + + + {isEdit + ? "The email account address cannot be modified when editing." + : "Please enter a Outlook email accessible via Graph API (e.g., @outlook.com or hotmail account)."} + + )} + /> + ( + + + Name: + + + + + Optional + + + )} + /> + ( + + + Incremental Sync(seconds): + + + field.onChange(parseInt(e.target.value, 10))} /> + + + Set the interval (in seconds) for calling the Graph Delta API for incremental sync. This determines how frequently updates are fetched for new or modified emails. + + + + )} + /> + ( + + Enabled: + + + + + Determines whether this account is active. If disabled, related syncs and queries will not run. + + + )} + /> + ( + + Minimal Sync: + + + + + {isEdit ? ( + "This setting cannot be modified after account creation." + ) : ( + "When enabled, Graph metadata will not be cached locally, ensuring higher synchronization efficiency by syncing only essential basic metadata fields." + )} + + + )} + /> + + Date Since: + + { + setRangeType(value); + if (value === 'none') { + form.setValue("date_since", undefined, { shouldValidate: true }); + } + + if (value === 'fixed') { + form.setValue("date_since", { fixed: undefined }, { shouldValidate: true }); + } + + if (value === 'relative') { + form.setValue("date_since", { relative: { value: undefined, unit: undefined } }, { shouldValidate: true }); + } + }} + className='flex flex-row space-x-4' + > + + + None + + + + Fixed + + + + Relative + + + defines the sync start date—either specific or relative to now. Preceding emails are excluded,{rangeType === 'fixed' ? " syncs data after a set date" : " shifts the sync date over time, syncing only recent data."} + {rangeType === 'fixed' && ( + + + + + + + + + { + if (value) { + const formattedDate = value.toLocaleDateString('en-CA') + field.onChange(formattedDate) + } else { + field.onChange(null) + } + }} + disabled={(date) => + date > new Date() || date < new Date("1900-01-01") + } + initialFocus + /> + + + + + )} + />} + {rangeType === 'relative' &&
+
+ ( + + + field.onChange(parseInt(e.target.value, 10))} /> + + + + )} + /> +
+
+ ( + + + + + )} + /> +
+
} + ( + + + Folder Sync Limit: + + + Limit the number of emails to sync per folder (minimum 100). Leave empty for no limit. + + + + field.onChange(e.target.value ? parseInt(e.target.value, 10) : undefined) + } + /> + + + + )} + /> + ( + + Use Proxy(optional): + + + + + Please use a http proxy for Graph API connections. + + + + )} + /> + + +
+ + + +
+
+ ); +} \ No newline at end of file diff --git a/web/src/features/accounts/components/running-state-dialog.tsx b/web/src/features/accounts/components/running-state-dialog.tsx index 573db81..5bde924 100644 --- a/web/src/features/accounts/components/running-state-dialog.tsx +++ b/web/src/features/accounts/components/running-state-dialog.tsx @@ -63,11 +63,20 @@ export function RunningStateDialog({ currentRow, open, onOpenChange }: Props) { // Helper function to render sync progress const renderSyncProgress = (current?: number | null, total?: number | null) => { - if (current === null || current === undefined || - total === null || total === undefined) { + if (current === null || current === undefined) { return n/a; } + if (total === null || total === undefined) { + return ( +
+ + Batch {current} + +
+ ); + } + const percentage = total > 0 ? Math.round((current / total) * 100) : 0; return (
diff --git a/web/src/features/accounts/context/index.tsx b/web/src/features/accounts/context/index.tsx index 5edc901..b6e0153 100644 --- a/web/src/features/accounts/context/index.tsx +++ b/web/src/features/accounts/context/index.tsx @@ -7,7 +7,7 @@ import React from 'react' import { AccountEntity } from '../data/schema' -export type AccountDialogType = 'imap-smtp-add' | 'imap-smtp-edit' | 'gmail-api-add' | 'gmail-api-edit' | 'delete' | 'detail' | 'oauth2' | 'running-state' | 'sync-folders' +export type AccountDialogType = 'imap-smtp-add' | 'imap-smtp-edit' | 'gmail-api-add' | 'gmail-api-edit' | 'delete' | 'detail' | 'oauth2' | 'running-state' | 'sync-folders' | 'graph-api-add' | 'graph-api-edit' interface AccountContextType { open: AccountDialogType | null diff --git a/web/src/features/accounts/data/schema.ts b/web/src/features/accounts/data/schema.ts index 1a69802..b6b5f65 100644 --- a/web/src/features/accounts/data/schema.ts +++ b/web/src/features/accounts/data/schema.ts @@ -68,4 +68,6 @@ export enum MailerType { ImapSmtp = "ImapSmtp", /** Use Gmail API */ GmailApi = "GmailApi", + /** Use Graph API */ + GraphApi = "GraphApi", } \ No newline at end of file diff --git a/web/src/features/accounts/index.tsx b/web/src/features/accounts/index.tsx index 5ccdf8f..f910a8a 100644 --- a/web/src/features/accounts/index.tsx +++ b/web/src/features/accounts/index.tsx @@ -28,6 +28,7 @@ import { FixedHeader } from '@/components/layout/fixed-header' import { SyncFoldersDialog } from './components/sync-folders' import { GmailApiAccountDialog } from './components/gmail-account-dialog' import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu' +import { GraphApiAccountDialog } from './components/outlook-account-dialog' export default function Accounts() { // Dialog states @@ -69,6 +70,9 @@ export default function Accounts() { setOpen("gmail-api-add")}> Gmail API + setOpen("graph-api-add")}> + Graph API +
@@ -88,9 +92,16 @@ export default function Accounts() {

You haven't added any Account configurations yet. Add one to start using Account features.

-
- - +
+ + +
@@ -110,6 +121,12 @@ export default function Accounts() { onOpenChange={() => setOpen('gmail-api-add')} /> + setOpen('graph-api-add')} + /> + {currentRow && ( <> + { + setOpen('graph-api-edit') + setTimeout(() => { + setCurrentRow(null) + }, 500) + }} + currentRow={currentRow} + /> + setOpen('detail')} currentRow={currentRow} /> - {( - (currentRow.mailer_type === MailerType.ImapSmtp && - currentRow.imap?.auth.auth_type === 'OAuth2') || - currentRow.mailer_type === MailerType.GmailApi - ) && setOpen('oauth2')} currentRow={currentRow} - />} + /> )} diff --git a/web/src/features/mailbox/components/mail-list.tsx b/web/src/features/mailbox/components/mail-list.tsx index b313a5f..386e176 100644 --- a/web/src/features/mailbox/components/mail-list.tsx +++ b/web/src/features/mailbox/components/mail-list.tsx @@ -7,7 +7,7 @@ import { cn, formatFileSize } from "@/lib/utils" import { Badge } from "@/components/ui/badge" import { formatDistanceToNow } from "date-fns" -import { EmailEnvelope, getBadgeVariantFromFlag, gmail_unread, isCustomFlag, seen } from "../data/schema" +import { EmailEnvelope, getBadgeVariantFromFlag, isCustomFlag } from "../data/schema" import { MailIcon, MailOpen, Paperclip, Trash2 } from "lucide-react" import { Skeleton } from "@/components/ui/skeleton" import { Checkbox } from "@/components/ui/checkbox" @@ -72,9 +72,7 @@ export function MailList({
{items.map((item) => { - const isUnread = item.labels && item.labels.length > 0 - ? gmail_unread(item) - : !seen(item); + const isUnread = !item.is_read; const hasAttachments = item.attachments && item.attachments.length > 0; const attachmentCount = item.attachments?.length || 0; @@ -107,9 +105,9 @@ export function MailList({ ) : ( )} - + {/* {isGmailApi ? `mid: ${item.id}` : `uid: ${item.id}`} - + */}

flag.flag === 'Seen'); -} - -export function gmail_unread(envelope: EmailEnvelope): boolean { - return envelope.labels.includes("UNREAD"); -} - export function getBadgeVariantFromFlag(flag: EmailFlag): "default" | "secondary" | "destructive" | "outline" | null | undefined { switch (flag) { case 'Deleted': @@ -47,8 +39,8 @@ export interface EmailEnvelope { id: string; internal_date?: number; size: number; - flags: EnvelopeFlag[]; - flags_hash: number; + flags?: EnvelopeFlag[]; + flags_hash?: number; bcc?: Addr[]; cc?: Addr[]; date?: number; @@ -68,6 +60,7 @@ export interface EmailEnvelope { body_meta?: EmailBodyPart[]; received?: Received; labels: string[]; + is_read: boolean }