feat(graph): add Graph API support and GraphAPI account type

This commit is contained in:
rustmailer
2025-10-28 19:39:49 +08:00
parent de8067f586
commit bbcc1348ff
47 changed files with 1219 additions and 187 deletions
+2
View File
@@ -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.
+2 -1
View File
@@ -210,7 +210,8 @@ pub enum MailerType {
/// Use IMAP/SMTP protocol
#[default]
ImapSmtp,
/// Use Gmail API
GmailApi,
/// Use Graph API
GraphApi,
}
+15 -5
View File
@@ -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?;
+2 -2
View File
@@ -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<u32>,
) -> 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
+1 -1
View File
@@ -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();
+13 -5
View File
@@ -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<Models> = LazyLock::new(|| {
let mut adapter = ModelsAdapter::new();
@@ -50,6 +55,9 @@ pub static ENVELOPE_MODELS: LazyLock<Models> = LazyLock::new(|| {
adapter.register_model::<GmailEnvelope>();
adapter.register_model::<GmailLabels>();
adapter.register_model::<GmailCheckPoint>();
adapter.register_model::<OutlookFolder>();
adapter.register_model::<FolderDeltaLink>();
adapter.register_model::<OutlookEnvelope>();
adapter.models
});
+5 -3
View File
@@ -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?;
}
+21
View File
@@ -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
)
}
}
}
}
}
+18 -5
View File
@@ -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<String>,
pub is_read: bool,
}
impl Envelope {
@@ -134,6 +143,10 @@ impl From<EmailEnvelopeV3> 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,
+2 -2
View File
@@ -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<Syn
SyncType::SkipSync
}
}
MailerType::GmailApi => {
MailerType::GmailApi | MailerType::GraphApi => {
if incremental_sync {
AccountRunningState::set_incremental_sync_start(account.id).await?;
SyncType::IncrementalSync
+4 -1
View File
@@ -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<String, String>) -> Envelope {
let is_read = self.label_ids.iter().any(|f| f == "UNREAD");
let labels: Vec<String> = self
.label_ids
.into_iter()
@@ -431,6 +433,7 @@ impl GmailEnvelope {
attachments: None,
body_meta: None,
received: None,
is_read,
labels,
}
}
+3 -3
View File
@@ -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?;
}
+1 -1
View File
@@ -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()
);
+32
View File
@@ -128,3 +128,35 @@ pub struct Attachment {
#[serde(rename = "microsoft.graph.fileAttachment/contentId")]
pub content_id: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct DeltaResponse {
#[serde(rename = "@odata.context")]
pub context: Option<String>,
#[serde(rename = "@odata.nextLink")]
pub next_link: Option<String>,
#[serde(rename = "@odata.deltaLink")]
pub delta_link: Option<String>,
pub value: Option<Vec<PartialMessage>>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct PartialMessage {
#[serde(rename = "@odata.etag")]
pub etag: Option<String>,
#[serde(rename = "@odata.type")]
pub odata_type: Option<String>,
pub id: String,
#[serde(rename = "@removed")]
pub removed: Option<RemovedInfo>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct RemovedInfo {
pub reason: String,
}
+34 -60
View File
@@ -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<String> {
pub async fn get_access_token(account_id: u64) -> RustMailerResult<String> {
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<MailFolder>,
access_token: &'a str,
) -> Pin<Box<dyn Future<Output = RustMailerResult<()>> + 'a>> {
) -> Pin<Box<dyn Future<Output = RustMailerResult<()>> + 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<u64>,
default_folder_name: &str,
) -> RustMailerResult<MailFolder> {
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::<MailFolder>(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<u64>,
folder_id: &str,
) -> RustMailerResult<String> {
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<u64>,
delta_link: &str,
) -> RustMailerResult<String> {
let mut url = delta_link.to_string();
id: &str,
) -> RustMailerResult<Message> {
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::<Message>(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)
}
}
+76
View File
@@ -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::<DeltaResponse>(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<OutlookFolder> {
let local_ids: AHashSet<_> = local_folders.iter().map(|l| &l.id).collect();
remote_folders
.iter()
.filter(|remote| local_ids.contains(&remote.id))
.cloned()
.collect()
}
+137 -21
View File
@@ -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<i64>,
/// 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<String>,
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<bool> {
let target = secondary_find_impl::<OutlookEnvelope>(
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<DataPage<OutlookEnvelope>> {
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<OutlookEnvelope> = 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<OutlookEnvelope>) -> 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::<OutlookEnvelope>(e.clone()).map_err(|err| {
raise_error!(format!("{:#?}", err), ErrorCode::InternalError)
})?;
}
}
Ok(())
@@ -252,18 +326,19 @@ impl TryFrom<Message> for OutlookEnvelope {
type Error = RustMailerError;
fn try_from(msg: Message) -> Result<Self, Self::Error> {
fn parse_datetime(dt: &Option<String>) -> RustMailerResult<i64> {
if let Some(s) = dt {
let parsed: DateTime<Utc> = 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<String>) -> RustMailerResult<Option<i64>> {
dt.as_ref()
.map(|s| {
s.parse::<DateTime<Utc>>()
.map(|dt| dt.timestamp_millis())
.map_err(|e| {
raise_error!(
format!("Invalid datetime {}: {}", s, e),
ErrorCode::InternalError
)
})
})
.transpose()
}
fn recipient_to_addr(r: &Option<Recipient>) -> Option<Addr> {
@@ -284,7 +359,7 @@ impl TryFrom<Message> 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<Message> 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<Message> 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<OutlookEnvelope> 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,
}
}
}
+2 -2
View File
@@ -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?;
}
+101 -9
View File
@@ -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<OutlookFolder> {
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<OutlookFolder> {
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(())
}
+2 -2
View File
@@ -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!(
+13 -9
View File
@@ -19,7 +19,7 @@ use crate::{
pub async fn get_sync_folders(account: &AccountModel) -> RustMailerResult<Vec<MailFolder>> {
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<Vec<Ma
)
);
}
//目前邮件夹的变更检测,仍然是根据name来做的,而不是根据id
detect_mailbox_changes(
account,
all_mail_folders
@@ -52,16 +52,16 @@ pub async fn get_sync_folders(account: &AccountModel) -> RustMailerResult<Vec<Ma
)
.await?;
//sync_folders stores the mailbox names for IMAP accounts, whereas for Gmail API accounts it stores the label IDs.
let subscribed = &account.sync_folders;
debug!(
"Account {}: Current subscribed sync folders: {:?}",
account.id, subscribed
);
// Filter folders according to the subscription list; matched_folders will not include any folders outside of it.
let mut matched_folders: Vec<&MailFolder> = if !subscribed.is_empty() {
let mut matched_folders: Vec<MailFolder> = 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<Vec<Ma
);
// If there are no subscriptions, default to the two special folders: inbox and sentitems
if matched_folders.is_empty() {
let inbox = OutlookClient::get_folder(account.id, account.use_proxy, "inbox").await?;
let sentitems =
OutlookClient::get_folder(account.id, account.use_proxy, "sentitems").await?;
matched_folders = all_mail_folders
.iter()
.filter(|label| label.display_name == "inbox" || label.display_name == "sentitems")
.into_iter()
.filter(|folder| folder.id == inbox.id || folder.id == sentitems.id)
.collect();
debug!(
"Account {}: Matched labels after default inbox/sentitems filter: {:?}",
"Account {}: Matched folders after default inbox/sentitems filter: {:?}",
account.id,
matched_folders
.iter()
@@ -103,5 +107,5 @@ pub async fn get_sync_folders(account: &AccountModel) -> RustMailerResult<Vec<Ma
);
}
}
Ok(all_mail_folders)
Ok(matched_folders)
}
+41 -10
View File
@@ -9,7 +9,9 @@ use std::{future::Future, pin::Pin, time::Duration};
use crate::{
modules::{
cache::vendor::outlook::model::{MailFolder, MailFoldersResponse, MessageListResponse},
cache::vendor::outlook::model::{
MailFolder, MailFoldersResponse, Message, MessageListResponse,
},
common::rustls::RustMailerTls,
context::Initialize,
error::{code::ErrorCode, RustMailerResult},
@@ -239,14 +241,7 @@ async fn test9() {
#[tokio::test]
async fn fetch_delta() {
let access_token = access_token().await;
let mut url =
"https://graph.microsoft.com/v1.0/me/mailFolders/inbox/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
"
.to_string();
let mut url = "https://graph.microsoft.com/v1.0/me/mailFolders/inbox/messages/delta?$select=id&$orderBy=receivedDateTime desc".to_string();
let client = reqwest::Client::builder()
.user_agent(rustmailer_version!())
.timeout(Duration::from_secs(10))
@@ -299,7 +294,7 @@ async fn fetch_delta() {
async fn fetch_delta2() {
let access_token = access_token().await;
let mut url =
"https://graph.microsoft.com/v1.0/me/mailFolders('inbox')/messages/delta?$deltatoken=EYiPJwhbWv_RaMa6Pmw5E6U1QisCj5AIWyPQxgvPgSDyuXXU857vfYaf26QiigAqyySdRPjjG994PaAGYFUP21FRTx72KYI0d_u9hmqzjaLodClcNv6iZoz_JWKmce_Lh_14n7M6GlNlrwJLfZk8iKYMu_92_U_tJ0nd-mU1gJkW7-4w6ntpwPdUhYJgrK56-hxHDhKX8Jp7hX1Lzx04UoXkZVp6pTZF4gcWmk9Xhe7aksxG78IAMRuuM8hQiuDX6iZsexO2Mr2uyZ5MdbqCVu0v_4dEfUBcP7n4W05KmmxYzYtloJ23DwLVxOf6COpnESHUuVDrvkIpAvoGWP53vM5vKY4anIjRR8NE0xTEnBJ7xK8cd3G-Iqh5W4ERY-BoXCMwR45HFc7pROR8Uy9eOkybW8gCn4ZjNboykOwv5SWcqBNnpoZWNthOKjfrHs-0JwCKJJhAOauOym_0Eyxx2V-C_9-pt9IYLwZ3EorNBi4ehtpMQecYi0mv0NbELpX36mfTwAQsCEU8lARAX_rU9XqWD2Spfuf9tWb5Dmc1hFhzZi8p6dGTOokLwlovOPhMm1mS_bF-dkhil9YqIqSlTg.ZGam2nknrDorPIfcCajMuRW0Vj_4l3eouiYvVaB48d8"
"https://graph.microsoft.com/v1.0/me/mailFolders('inbox')/messages/delta?$deltatoken=gGpE9bWe1qFWcyG3HvbWkkunspLvA4xxmWqkeJWDye811LoJPSEs2ZjUPG0pKudlwejI_rsTDgNJAu_zA2ex-FgAPO2Di5TtbAbBZHRD2W4wUQqFja-VKSwMWTD2WTQ40_86JhGVgDhA_GpxYC-BBtGflJttseEOr6eCZyMVGlI.i7MKxGEvGLJfbZxhsubwycVsTVIyTbv4AIJH78wjsog"
.to_string();
let client = reqwest::Client::builder()
.user_agent(rustmailer_version!())
@@ -389,3 +384,39 @@ async fn list_messages() {
eprintln!("Error: {} - {:?}", res.status(), res.text().await.unwrap());
}
}
#[tokio::test]
async fn get_message() {
let access_token = access_token().await;
let mut url =
"https://graph.microsoft.com/v1.0/me/messages/AQMkADAwATMwMAItNzE0OC1jZTEzLTAwAi0wMAoARgAAA_KUk7xWPSBEntPHShr61lgHAOo9V4GwHndCjf0x1uoIcwUAAAIBDAAAAOo9V4GwHndCjf0x1uoIcwUAAYiJVH0AAAA=?\
$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)"
.to_string();
let client = reqwest::Client::builder()
.user_agent(rustmailer_version!())
.timeout(Duration::from_secs(10))
.connect_timeout(Duration::from_secs(10))
.proxy(reqwest::Proxy::all("http://127.0.0.1:22307").unwrap())
.redirect(reqwest::redirect::Policy::none())
.build()
.unwrap();
let mut nextlink_count = 0;
let res = client
.get(&url)
.header(AUTHORIZATION, format!("Bearer {}", access_token))
.header(CONTENT_TYPE, "application/json")
.send()
.await
.unwrap();
if res.status().is_success() {
let body: Message = res.json().await.unwrap();
println!("{:#?}", body);
} else {
eprintln!("Error: {} - {:?}", res.status(), res.text().await.unwrap());
}
}
+2
View File
@@ -353,6 +353,7 @@ impl TryFrom<i32> 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<MailerType> for i32 {
match value {
MailerType::ImapSmtp => 0,
MailerType::GmailApi => 1,
MailerType::GraphApi => 2,
}
}
}
+1
View File
@@ -91,5 +91,6 @@ pub async fn create_mailbox(
MailerType::GmailApi => {
GmailClient::create_label(account_id, account.use_proxy, request).await
}
MailerType::GraphApi => todo!(),
}
}
+1
View File
@@ -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!(),
}
}
+17 -1
View File
@@ -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::<RustMailerResult<Vec<MailBox>>>()?;
Ok(mailboxes)
}
(MailerType::GraphApi, false) => {
let folders = OutlookFolder::list_all(account_id).await?;
Ok(folders.into_iter().map(Into::into).collect())
}
}
}
+1
View File
@@ -70,5 +70,6 @@ pub async fn update_mailbox(
})?;
GmailClient::update_label(account_id, account.use_proxy, label_id, &payload).await
}
MailerType::GraphApi => todo!(),
}
}
+1
View File
@@ -108,6 +108,7 @@ impl AppendReplyToDraftRequest {
self.append_reply_to_draft_gmail(&account, account_id)
.await?
}
MailerType::GraphApi => todo!(),
}
Ok(())
+2
View File
@@ -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!(),
}
}
+2
View File
@@ -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!(),
}
}
+1
View File
@@ -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!(),
}
}
+1
View File
@@ -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!(),
}
}
+43 -3
View File
@@ -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!(),
}
}
+2
View File
@@ -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);
}
+1
View File
@@ -178,5 +178,6 @@ pub async fn transfer_messages(
}
}
}
MailerType::GraphApi => todo!(),
}
}
+1
View File
@@ -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())),
+1
View File
@@ -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(
+1
View File
@@ -322,6 +322,7 @@ impl Task for SmtpTask {
}
}
}
MailerType::GraphApi => todo!(),
}
})
}
@@ -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;
}
}}
>
@@ -12,20 +12,31 @@ import { AccountEntity, MailerType } from '../data/schema'
interface DataTableRowActionsProps {
row: Row<AccountEntity>
}
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
) ? (
<Button variant='ghost' onClick={() => {
setCurrentRow(row.original)
setOpen('oauth2')
}}><span className="text-xs text-blue-500 cursor-pointer underline hover:text-blue-700">OAuth2</span></Button>
) : (
<span className="text-xs cursor-pointer">Password</span>
)
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 (
<Button
variant="ghost"
size="sm"
className="text-xs text-blue-500 hover:text-blue-700 underline"
onClick={() => {
setCurrentRow(mailer)
setOpen("oauth2")
}}
>
OAuth2
</Button>
)
}
return <span className="text-xs text-muted-foreground">Password</span>
}
@@ -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<GraphApiAccount>({
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<string, any>) => 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: <ToastAction altText="Close">Close</ToastAction>,
});
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: <ToastAction altText="Try again">Try again</ToastAction>,
});
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 (
<Dialog
open={open}
onOpenChange={(state) => {
form.reset();
onOpenChange(state);
}}
>
<DialogContent className='max-w-4xl'>
<DialogHeader className='text-left mb-4'>
<DialogTitle>{isEdit ? "Update Account" : "Add Account"}</DialogTitle>
<DialogDescription>
{isEdit ? 'Update the email account here. ' : 'Add new email account here. '}
Click save when you're done.
</DialogDescription>
</DialogHeader>
<ScrollArea className='h-[40rem] w-full pr-4 -mr-4 py-1'>
<Form {...form}>
<form
id='gmail-api-account-form'
onSubmit={form.handleSubmit(onSubmit)}
className='space-y-4 p-0.5'
>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel className="flex items-center justify-between">
Email Address:
</FormLabel>
<FormControl>
<Input placeholder="e.g john.doe@outlook.com" readOnly={isEdit} {...field} />
</FormControl>
<FormMessage />
<FormDescription>{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)."}</FormDescription>
</FormItem>
)}
/>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel className="flex items-center justify-between">
Name:
</FormLabel>
<FormControl>
<Input placeholder="e.g john.doe" {...field} />
</FormControl>
<FormDescription>Optional</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="incremental_sync_interval_sec"
render={({ field }) => (
<FormItem>
<FormLabel className="flex items-center justify-between">
Incremental Sync(seconds):
</FormLabel>
<FormControl>
<Input type="number" placeholder="e.g 300" {...field} onChange={(e) => field.onChange(parseInt(e.target.value, 10))} />
</FormControl>
<FormDescription>
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.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='enabled'
render={({ field }) => (
<FormItem className='flex flex-col items-start gap-y-1'>
<FormLabel>Enabled:</FormLabel>
<FormControl>
<Checkbox
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<FormDescription>
Determines whether this account is active. If disabled, related syncs and queries will not run.
</FormDescription>
</FormItem>
)}
/>
<FormField
control={form.control}
name='minimal_sync'
render={({ field }) => (
<FormItem className='flex flex-col items-start gap-y-1'>
<FormLabel>Minimal Sync:</FormLabel>
<FormControl>
<Checkbox
className='mt-2'
checked={field.value}
onCheckedChange={isEdit ? undefined : field.onChange}
disabled={isEdit}
/>
</FormControl>
<FormDescription>
{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."
)}
</FormDescription>
</FormItem>
)}
/>
<FormLabel className="flex items-center justify-between">
Date Since:
</FormLabel>
<RadioGroup
defaultValue={rangeType}
onValueChange={(value: 'fixed' | 'relative' | 'none') => {
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'
>
<FormItem className='flex items-center space-x-3'>
<RadioGroupItem value='none' />
<FormLabel className='font-normal'>None</FormLabel>
</FormItem>
<FormItem className='flex items-center space-x-3'>
<RadioGroupItem value='fixed' />
<FormLabel className='font-normal'>Fixed</FormLabel>
</FormItem>
<FormItem className='flex items-center space-x-3'>
<RadioGroupItem value='relative' />
<FormLabel className='font-normal'>Relative</FormLabel>
</FormItem>
</RadioGroup>
<FormDescription>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."}</FormDescription>
{rangeType === 'fixed' && <FormField
control={form.control}
name="date_since.fixed"
render={({ field }) => (
<FormItem className="flex flex-col">
<Popover>
<PopoverTrigger asChild>
<FormControl>
<Button
variant={"outline"}
className={cn(
"w-[240px] pl-3 text-left font-normal text-sm text-brand-marine-blue",
!field.value && "text-muted-foreground"
)}
>
{field.value ? (
format(field.value, "PPP")
) : (
<span>Pick a date</span>
)}
<CalendarIcon className="ml-auto h-4 w-4 opacity-50" />
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="single"
selected={field.value ? new Date(new Date(field.value).setHours(0, 0, 0, 0)) : undefined}
onSelect={(value) => {
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
/>
</PopoverContent>
</Popover>
<FormMessage />
</FormItem>
)}
/>}
{rangeType === 'relative' && <div className="flex flex-row gap-4">
<div className="flex-1">
<FormField
control={form.control}
name="date_since.relative.value"
render={({ field }) => (
<FormItem>
<FormControl>
<Input type="number" placeholder="e.g 1" {...field} onChange={(e) => field.onChange(parseInt(e.target.value, 10))} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="w-1/2">
<FormField
control={form.control}
name="date_since.relative.unit"
render={({ field }) => (
<FormItem>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select unit" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="Days">Days</SelectItem>
<SelectItem value="Months">Months</SelectItem>
<SelectItem value="Years">Years</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
</div>
</div>}
<FormField
control={form.control}
name="folder_limit"
render={({ field }) => (
<FormItem>
<FormLabel className="flex items-center justify-between">
Folder Sync Limit:
</FormLabel>
<FormDescription>
Limit the number of emails to sync per folder (minimum 100). Leave empty for no limit.
</FormDescription>
<FormControl>
<Input
type="number"
placeholder="e.g. 1000"
{...field}
onChange={(e) =>
field.onChange(e.target.value ? parseInt(e.target.value, 10) : undefined)
}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='use_proxy'
render={({ field }) => (
<FormItem>
<FormLabel className="flex items-center justify-between">Use Proxy(optional):</FormLabel>
<FormControl>
<Select
onValueChange={(val) => field.onChange(Number(val))}
defaultValue={field.value?.toString()}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select a proxy" />
</SelectTrigger>
</FormControl>
<SelectContent>
{proxyOptions && proxyOptions.length > 0 ? (
proxyOptions.map((option) => (
<SelectItem key={option.value} value={option.value.toString()}>
{option.label}
</SelectItem>
))
) : (
<SelectItem disabled value="__none__">No proxy available</SelectItem>
)}
</SelectContent>
</Select>
</FormControl>
<FormDescription className='flex-1'>
Please use a http proxy for Graph API connections.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</ScrollArea>
<DialogFooter>
<Button
type='submit'
form='gmail-api-account-form'
disabled={isEdit ? updateMutation.isPending : createMutation.isPending}
>
{isEdit ? (
updateMutation.isPending ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Saving...
</>
) : (
"Save changes"
)
) : (
createMutation.isPending ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Creating...
</>
) : (
"Create"
)
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -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 <span className="text-muted-foreground">n/a</span>;
}
if (total === null || total === undefined) {
return (
<div className="flex items-center gap-2">
<span className="text-sm font-medium">
Batch {current}
</span>
</div>
);
}
const percentage = total > 0 ? Math.round((current / total) * 100) : 0;
return (
<div className="flex items-center gap-2">
+1 -1
View File
@@ -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
+2
View File
@@ -68,4 +68,6 @@ export enum MailerType {
ImapSmtp = "ImapSmtp",
/** Use Gmail API */
GmailApi = "GmailApi",
/** Use Graph API */
GraphApi = "GraphApi",
}
+35 -9
View File
@@ -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() {
<DropdownMenuItem onClick={() => setOpen("gmail-api-add")}>
Gmail API
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setOpen("graph-api-add")}>
Graph API
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
@@ -88,9 +92,16 @@ export default function Accounts() {
<p className="mb-4 mt-2 text-sm text-muted-foreground">
You haven't added any Account configurations yet. Add one to start using Account features.
</p>
<div className="flex gap-4">
<Button onClick={() => setOpen("imap-smtp-add")}>Add IMAP/SMTP Configuration</Button>
<Button onClick={() => setOpen("gmail-api-add")}>Add Gmail API Configuration</Button>
<div className="mt-4 flex flex-col items-center gap-3 sm:flex-row sm:flex-wrap sm:justify-center sm:gap-4">
<Button variant="default" className="w-64" onClick={() => setOpen("imap-smtp-add")}>
Add IMAP/SMTP Configuration
</Button>
<Button variant="default" className="w-64" onClick={() => setOpen("gmail-api-add")}>
Add Gmail API Configuration
</Button>
<Button variant="default" className="w-64" onClick={() => setOpen("graph-api-add")}>
Add Graph API Configuration
</Button>
</div>
</div>
</div>
@@ -110,6 +121,12 @@ export default function Accounts() {
onOpenChange={() => setOpen('gmail-api-add')}
/>
<GraphApiAccountDialog
key='graph-api-account-add'
open={open === 'graph-api-add'}
onOpenChange={() => setOpen('graph-api-add')}
/>
{currentRow && (
<>
<AccountActionDialog
@@ -136,6 +153,18 @@ export default function Accounts() {
currentRow={currentRow}
/>
<GraphApiAccountDialog
key={`graph-api-account-edit-${currentRow.id}`}
open={open === 'graph-api-edit'}
onOpenChange={() => {
setOpen('graph-api-edit')
setTimeout(() => {
setCurrentRow(null)
}, 500)
}}
currentRow={currentRow}
/>
<RunningStateDialog
key='running-state'
open={open === 'running-state'}
@@ -175,14 +204,11 @@ export default function Accounts() {
onOpenChange={() => setOpen('detail')}
currentRow={currentRow}
/>
{(
(currentRow.mailer_type === MailerType.ImapSmtp &&
currentRow.imap?.auth.auth_type === 'OAuth2') ||
currentRow.mailer_type === MailerType.GmailApi
) && <OAuth2TokensDialog open={open === 'oauth2'}
<OAuth2TokensDialog open={open === 'oauth2'}
onOpenChange={() => setOpen('oauth2')}
currentRow={currentRow}
/>}
/>
</>
)}
</AccountProvider>
@@ -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({
<div className="grid grid-cols-1 gap-1.5 p-1 sm:p-2">
{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({
) : (
<MailOpen className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
)}
<span className="text-xs text-muted-foreground">
{/* <span className="text-xs text-muted-foreground">
{isGmailApi ? `mid: ${item.id}` : `uid: ${item.id}`}
</span>
</span> */}
<p className={cn(
"text-xs font-medium truncate ml-1",
+3 -10
View File
@@ -20,14 +20,6 @@ export function isCustomFlag(flag: EmailFlag): boolean {
return flag === 'Custom';
}
export function seen(envelope: EmailEnvelope): boolean {
return envelope.flags.some(flag => 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
}