feat(api): Introduce unified tag-messages interface for mail tagging

Introduces a new `/tag-messages` API endpoint that provides a consistent, unified capability for setting tags (Labels/Categories/Keywords) across different mailer types.
This commit is contained in:
rustmailer
2025-11-21 12:58:58 +08:00
parent 973fe406f1
commit f9857fe9ae
12 changed files with 541 additions and 43 deletions
+6 -3
View File
@@ -19,10 +19,13 @@ use crate::{
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize, Object)]
pub struct Envelope {
/// The unique ID of the message, either IMAP UID or Gmail API MID.
/// The unique identifier of the email message across different systems.
///
/// - For IMAP accounts, this is the UID converted to a string.
/// - For Gmail API accounts, this is the message ID returned by the API.
/// This field maps to the primary message ID used by the respective API or protocol:
///
/// - **For IMAP accounts:** This is the **UID** (Unique Identifier) converted to a string.
/// - **For Gmail API accounts:** This is the **Message ID (MID)** returned by the Gmail API.
/// - **For Microsoft Graph API accounts:** This is the **ID** property (e.g., the base64-encoded EWS ID) of the message object.
pub id: String,
/// The ID of the account owning the email.
pub account_id: u64,
+5 -5
View File
@@ -2,8 +2,8 @@
// Licensed under RustMailer License Agreement v1.0
// Unauthorized copying, modification, or distribution is prohibited.
use serde::{Deserialize, Serialize};
use crate::modules::cache::vendor::gmail::sync::labels::GmailLabels;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct LabelList {
@@ -39,10 +39,10 @@ pub struct LabelDetail {
pub message_list_visibility: Option<String>,
/// Total number of messages with this label
#[serde(rename = "messagesTotal")]
pub messages_total: u32,
pub messages_total: Option<u32>,
/// Number of unread messages with this label
#[serde(rename = "messagesUnread")]
pub messages_unread: u32,
pub messages_unread: Option<u32>,
/// Display name of the label
pub name: String,
/// Total number of threads with this label
@@ -65,8 +65,8 @@ impl From<LabelDetail> for GmailLabels {
id: 0,
account_id: 0,
name: label.name,
exists: label.messages_total,
unseen: label.messages_unread,
exists: label.messages_total.unwrap_or_default(),
unseen: label.messages_unread.unwrap_or_default(),
label_id: label.id,
}
}
+12 -16
View File
@@ -43,7 +43,7 @@ impl GmailClient {
pub async fn list_labels(
account_id: u64,
use_proxy: Option<u64>,
) -> RustMailerResult<LabelList> {
) -> RustMailerResult<Vec<Label>> {
let url = "https://gmail.googleapis.com/gmail/v1/users/me/labels";
let client = HttpClient::new(use_proxy).await?;
let access_token = Self::get_access_token(account_id).await?;
@@ -53,16 +53,7 @@ impl GmailClient {
"Failed to deserialize Gmail API response into LabelList: {:#?}. Possible model mismatch or API change.",
e
), ErrorCode::InternalError))?;
Ok(list)
}
pub async fn list_visible_labels(
account_id: u64,
use_proxy: Option<u64>,
) -> RustMailerResult<Vec<Label>> {
let all_labels = Self::list_labels(account_id, use_proxy).await?;
let visible_labels: Vec<Label> = all_labels.labels;
Ok(visible_labels)
Ok(list.labels)
}
pub async fn label_map(
@@ -72,7 +63,7 @@ impl GmailClient {
if let Some(v) = GMAIL_LABELS_CACHE.get(&account_id).await {
return Ok(v.clone());
}
let visible_labels = Self::list_visible_labels(account_id, use_proxy).await?;
let visible_labels = Self::list_labels(account_id, use_proxy).await?;
let map: Arc<AHashMap<String, String>> = Arc::new(
visible_labels
.into_iter()
@@ -95,7 +86,7 @@ impl GmailClient {
return Ok(map);
}
}
let visible_labels = Self::list_visible_labels(account_id, use_proxy).await?;
let visible_labels = Self::list_labels(account_id, use_proxy).await?;
let map: Arc<AHashMap<String, String>> = Arc::new(
visible_labels
.into_iter()
@@ -132,7 +123,7 @@ impl GmailClient {
account_id: u64,
use_proxy: Option<u64>,
request: &CreateMailboxRequest,
) -> RustMailerResult<()> {
) -> RustMailerResult<LabelDetail> {
let url = "https://gmail.googleapis.com/gmail/v1/users/me/labels";
let client = HttpClient::new(use_proxy).await?;
@@ -149,8 +140,13 @@ impl GmailClient {
});
}
let access_token = Self::get_access_token(account_id).await?;
client.post(url, &access_token, Some(&body), true).await?;
Ok(())
let value = client.post(url, &access_token, Some(&body), true).await?;
let detail = serde_json::from_value::<LabelDetail>(value)
.map_err(|e| raise_error!(format!(
"Failed to deserialize Gmail API response into LabelDetail: {:#?}. Possible model mismatch or API change.",
e
), ErrorCode::InternalError))?;
Ok(detail)
}
pub async fn delete_label(
+10 -10
View File
@@ -19,24 +19,24 @@ use crate::{
};
pub async fn get_sync_labels(account: &AccountModel) -> RustMailerResult<Vec<LabelDetail>> {
let visible_labels = GmailClient::list_visible_labels(account.id, account.use_proxy).await?;
let all_labels = GmailClient::list_labels(account.id, account.use_proxy).await?;
debug!(
"Account {}: Retrieved {} visible labels from Gmail API: {:?}",
"Account {}: Retrieved {} labels from Gmail API: {:?}",
account.id,
visible_labels.len(),
visible_labels.iter().map(|l| &l.name).collect::<Vec<_>>()
all_labels.len(),
all_labels.iter().map(|l| &l.name).collect::<Vec<_>>()
);
// Exclude all labels that cannot retrieve messages via the message list,
// since we use the message API to fetch message details.
if visible_labels.is_empty() {
if all_labels.is_empty() {
warn!(
"Account {}: No visible labels returned from Gmail API.",
"Account {}: No labels returned from Gmail API.",
account.id
);
return Err(
raise_error!(
format!(
"No visible labels returned from Gmail API for account {}. This is unexpected and may indicate an issue with the Gmail API or data.",
"No labels returned from Gmail API for account {}. This is unexpected and may indicate an issue with the Gmail API or data.",
account.id
),
ErrorCode::InternalError
@@ -46,7 +46,7 @@ pub async fn get_sync_labels(account: &AccountModel) -> RustMailerResult<Vec<Lab
// Detect label changes through this method and send notifications.
detect_mailbox_changes(
account,
visible_labels
all_labels
.iter()
.map(|label| label.name.clone())
.collect(),
@@ -61,7 +61,7 @@ pub async fn get_sync_labels(account: &AccountModel) -> RustMailerResult<Vec<Lab
);
// Filter labels according to the subscription list; matched_labels will not include any labels outside of it.
let mut matched_labels: Vec<&Label> = if !subscribed.is_empty() {
visible_labels
all_labels
.iter()
.filter(|label| subscribed.contains(&label.id))
.collect()
@@ -75,7 +75,7 @@ pub async fn get_sync_labels(account: &AccountModel) -> RustMailerResult<Vec<Lab
);
// If there are no subscriptions, default to the two special labels: INBOX and SENT
if matched_labels.is_empty() {
matched_labels = visible_labels
matched_labels = all_labels
.iter()
.filter(|label| label.id == "INBOX" || label.id == "SENT")
.collect();
+1 -1
View File
@@ -428,4 +428,4 @@ async fn test9() {
} else {
eprintln!("Error: {} - {:?}", res.status(), res.text().await.unwrap());
}
}
}
+152 -1
View File
@@ -14,7 +14,7 @@ use crate::{
},
raise_error,
};
use std::{future::Future, pin::Pin};
use std::{collections::HashMap, future::Future, pin::Pin};
pub struct OutlookClient;
@@ -347,6 +347,152 @@ impl OutlookClient {
})
}
pub async fn batch_get_categories(
account_id: u64,
use_proxy: Option<u64>,
mids: &[String],
) -> RustMailerResult<HashMap<String, Vec<String>>> {
let url = "https://graph.microsoft.com/v1.0/$batch";
let client = HttpClient::new(use_proxy).await?;
let access_token = Self::get_access_token(account_id).await?;
let mut requests = Vec::new();
for (_, mid) in mids.iter().enumerate() {
let request = json!({
"id": mid,
"method": "GET",
"url": format!("/me/messages/{}?$select=categories", mid),
"headers": {}
});
requests.push(request);
}
let batch_body = json!({
"requests": requests
});
let response_value = client
.post(url, &access_token, Some(&batch_body), true)
.await?;
let responses = response_value
.get("responses")
.ok_or_else(|| raise_error!("Missing 'responses' array in $batch response.".into(), ErrorCode::InternalError))?
.as_array()
.ok_or_else(|| raise_error!("'responses' field is not an array.".into(), ErrorCode::InternalError))?;
let mut categories_map = HashMap::new();
for res in responses {
let batch_id = res.get("id").and_then(|v| v.as_str()).unwrap_or("Unknown ID");
let status = res.get("status").and_then(|s| s.as_i64()).unwrap_or(500);
if status >= 400 {
eprintln!(
"Graph $batch GET sub-request failed for MID {} with status {}. Error body: {:?}",
batch_id,
status,
res.get("body")
);
return Err(raise_error!(
format!(
"Failed to get categories for one or more messages (MID: {}, Status: {}). Please try again.",
batch_id,
status
),
ErrorCode::ApiCallFailed
));
}
if status == 200 || status == 203 {
if let Some(body) = res.get("body") {
let categories = body
.get("categories")
.and_then(|c| c.as_array())
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str().map(|s| s.to_string()))
.collect::<Vec<String>>()
})
.unwrap_or_default();
categories_map.insert(batch_id.to_string(), categories);
}
}
}
Ok(categories_map)
}
pub async fn batch_modify_categories(
account_id: u64,
use_proxy: Option<u64>,
updates: &[MessageCategoryUpdate],
) -> RustMailerResult<()> {
let url = "https://graph.microsoft.com/v1.0/$batch";
let client = HttpClient::new(use_proxy).await?;
let access_token = Self::get_access_token(account_id).await?;
let mut requests = Vec::new();
for (index, update) in updates.iter().enumerate() {
let request = json!({
"id": (index + 1).to_string(),
"method": "PATCH",
"url": format!("/me/messages/{}", update.mid),
"body": {
"categories": &update.categories,
},
"headers": {
"Content-Type": "application/json"
}
});
requests.push(request);
}
let batch_body = json!({
"requests": requests
});
let response_value = client
.post(url, &access_token, Some(&batch_body), true)
.await?;
let responses = response_value
.get("responses")
.ok_or_else(|| {
raise_error!(
"Missing 'responses' array in $batch response.".into(),
ErrorCode::InternalError
)
})?
.as_array()
.ok_or_else(|| {
raise_error!(
"'responses' field is not an array.".into(),
ErrorCode::InternalError
)
})?;
for res in responses {
if let Some(status) = res.get("status").and_then(|s| s.as_i64()) {
if status >= 400 {
return Err(raise_error!(
format!(
"Graph $batch sub-request failed for id {} with status {}.",
res.get("id")
.unwrap_or(&json!("Unknown"))
.as_str()
.unwrap_or("Unknown"),
status
),
ErrorCode::ApiCallFailed
));
}
}
}
Ok(())
}
pub async fn copy_message(
account_id: u64,
use_proxy: Option<u64>,
@@ -458,3 +604,8 @@ impl OutlookClient {
Ok(())
}
}
pub struct MessageCategoryUpdate {
pub mid: String,
pub categories: Vec<String>,
}