mirror of
https://github.com/rustmailer/rustmailer.git
synced 2026-08-21 00:01:21 +00:00
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:
Vendored
+6
-3
@@ -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
@@ -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
@@ -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
@@ -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
@@ -428,4 +428,4 @@ async fn test9() {
|
||||
} else {
|
||||
eprintln!("Error: {} - {:?}", res.status(), res.text().await.unwrap());
|
||||
}
|
||||
}
|
||||
}
|
||||
+152
-1
@@ -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>,
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
use crate::modules::cache::imap::mailbox::EnvelopeFlag;
|
||||
use crate::modules::error::code::ErrorCode;
|
||||
use crate::modules::{error::RustMailerResult, imap::manager::ImapConnectionManager};
|
||||
use crate::raise_error;
|
||||
use crate::{encode_mailbox_name, raise_error};
|
||||
use async_imap::types::{Fetch, Mailbox, Name};
|
||||
use bb8::Pool;
|
||||
use futures::{StreamExt, TryStreamExt};
|
||||
@@ -588,6 +588,8 @@ impl ImapExecutor {
|
||||
));
|
||||
}
|
||||
|
||||
let mailbox_name = &encode_mailbox_name!(mailbox_name);
|
||||
|
||||
let mut result = Vec::new();
|
||||
// Helper to convert flags to IMAP string
|
||||
let flags_to_string = |flags: &[EnvelopeFlag]| -> RustMailerResult<String> {
|
||||
|
||||
@@ -95,7 +95,8 @@ pub async fn create_mailbox(
|
||||
.await
|
||||
}
|
||||
MailerType::GmailApi => {
|
||||
GmailClient::create_label(account_id, account.use_proxy, request).await
|
||||
GmailClient::create_label(account_id, account.use_proxy, request).await?;
|
||||
Ok(())
|
||||
}
|
||||
MailerType::GraphApi => {
|
||||
OutlookClient::create_folder(
|
||||
|
||||
@@ -7,7 +7,7 @@ use std::sync::Arc;
|
||||
use crate::modules::account::entity::MailerType;
|
||||
use crate::modules::account::migration::AccountModel;
|
||||
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::model::labels::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;
|
||||
@@ -75,12 +75,11 @@ pub async fn request_imap_all_mailbox_list(account_id: u64) -> RustMailerResult<
|
||||
|
||||
pub async fn request_gmail_label_list(account: &AccountModel) -> RustMailerResult<Vec<MailBox>> {
|
||||
let all_labels = GmailClient::list_labels(account.id, account.use_proxy).await?;
|
||||
let visible_labels: Vec<Label> = all_labels.labels;
|
||||
|
||||
let mut tasks = Vec::new();
|
||||
|
||||
let account = Arc::new(account.clone());
|
||||
for label in visible_labels.into_iter() {
|
||||
for label in all_labels.into_iter() {
|
||||
let label_id = label.id.clone();
|
||||
let account = account.clone();
|
||||
let task: tokio::task::JoinHandle<Result<LabelDetail, RustMailerError>> =
|
||||
|
||||
@@ -9,15 +9,16 @@ use crate::{encode_mailbox_name, raise_error};
|
||||
|
||||
use crate::modules::{envelope::MinimalEnvelopeMeta, error::RustMailerResult};
|
||||
|
||||
pub mod append;
|
||||
pub mod attachment;
|
||||
pub mod content;
|
||||
pub mod transfer;
|
||||
pub mod delete;
|
||||
pub mod flag;
|
||||
pub mod full;
|
||||
pub mod list;
|
||||
pub mod search;
|
||||
pub mod append;
|
||||
pub mod tags;
|
||||
pub mod transfer;
|
||||
|
||||
pub async fn get_minimal_meta(
|
||||
account_id: u64,
|
||||
|
||||
@@ -0,0 +1,316 @@
|
||||
// Copyright © 2025 rustmailer.com
|
||||
// Licensed under RustMailer License Agreement v1.0
|
||||
// Unauthorized copying, modification, or distribution is prohibited.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use poem_openapi::{Enum, Object};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{
|
||||
modules::{
|
||||
account::{entity::MailerType, migration::AccountModel},
|
||||
cache::{
|
||||
imap::mailbox::{EmailFlag, EnvelopeFlag},
|
||||
vendor::{
|
||||
gmail::sync::client::GmailClient,
|
||||
outlook::sync::client::{MessageCategoryUpdate, OutlookClient},
|
||||
},
|
||||
},
|
||||
context::executors::RUST_MAIL_CONTEXT,
|
||||
envelope::generate_uid_set,
|
||||
error::{code::ErrorCode, RustMailerResult},
|
||||
mailbox::create::CreateMailboxRequest,
|
||||
},
|
||||
raise_error,
|
||||
};
|
||||
|
||||
const MAX_MESSAGE_IDS: usize = 50;
|
||||
|
||||
/// Defines the type of operation to be performed on a tag/category.
|
||||
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize, Enum)]
|
||||
pub enum TagAction {
|
||||
/// Adds one or more tags to the specified messages.
|
||||
#[default]
|
||||
Add,
|
||||
/// Removes one or more tags from the specified messages.
|
||||
Remove,
|
||||
/// Sets/overwrites the entire list of tags on the specified messages.
|
||||
/// (Note: This might require fetching the existing tags first for Graph API Add/Remove logic).
|
||||
Set,
|
||||
}
|
||||
|
||||
/// The unified request payload for batch tagging operations across different email APIs (e.g., Gmail, Graph).
|
||||
/// This structure abstracts the intention of modifying tags on a batch of messages.
|
||||
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize, Object)]
|
||||
pub struct BatchTagRequest {
|
||||
/// Required: A list of unique identifiers (Message IDs) for the emails to be operated on.
|
||||
pub message_ids: Vec<String>,
|
||||
|
||||
/// Required: The list of tags (which could be Label IDs for Gmail or Category Names for Graph API)
|
||||
/// to be added, removed, or set.
|
||||
pub tags: Vec<String>,
|
||||
|
||||
/// Required: The action to be performed on the 'tags' list.
|
||||
pub action: TagAction,
|
||||
|
||||
/// Required for IMAP operations to specify the mailbox context where the message UIDs are valid.
|
||||
/// Example: "INBOX", "Sent Items", "Project X/Subfolder"
|
||||
pub mailbox_name: Option<String>,
|
||||
|
||||
/// Optional: **Used only in the Gmail API scenario.**
|
||||
/// Specifies whether a tag/Label should be automatically created if it does not exist
|
||||
/// when referenced in the request.
|
||||
/// - If set to 'None' or 'false', an error will be returned if the tag is not found.
|
||||
/// - **This field will be ignored in other MailerTypes (e.g., IMAP).**
|
||||
pub auto_create_tags: Option<bool>,
|
||||
}
|
||||
|
||||
impl BatchTagRequest {
|
||||
pub fn validate(&self, account: &AccountModel) -> RustMailerResult<()> {
|
||||
if self.tags.is_empty() {
|
||||
return Err(raise_error!(
|
||||
"The 'tags' list cannot be empty. At least one tag must be specified.".into(),
|
||||
ErrorCode::InvalidParameter
|
||||
));
|
||||
}
|
||||
|
||||
if self.message_ids.is_empty() {
|
||||
return Err(raise_error!(
|
||||
"The 'message_ids' list must contain at least one message ID.".into(),
|
||||
ErrorCode::InvalidParameter
|
||||
));
|
||||
}
|
||||
|
||||
if self.message_ids.len() > MAX_MESSAGE_IDS {
|
||||
return Err(raise_error!(
|
||||
format!(
|
||||
"The 'message_ids' list is too long (Max {} IDs allowed for batch operations).",
|
||||
MAX_MESSAGE_IDS
|
||||
),
|
||||
ErrorCode::InvalidParameter
|
||||
));
|
||||
}
|
||||
|
||||
if matches!(account.mailer_type, MailerType::ImapSmtp) {
|
||||
if self.mailbox_name.is_none() {
|
||||
return Err(raise_error!(
|
||||
"The 'mailbox_name' field is required for IMAP/SMTP accounts to specify the folder context for message UIDs.".into(),
|
||||
ErrorCode::InvalidParameter
|
||||
));
|
||||
}
|
||||
for mid in &self.message_ids {
|
||||
if mid.parse::<u32>().is_err() {
|
||||
return Err(raise_error!(
|
||||
format!(
|
||||
"IMAP message IDs must be valid unsigned 32-bit integers (UIDs). Found invalid ID: '{}'",
|
||||
mid
|
||||
),
|
||||
ErrorCode::InvalidParameter
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn tag_messages_impl(account_id: u64, payload: BatchTagRequest) -> RustMailerResult<()> {
|
||||
let account = AccountModel::check_account_active(account_id, false).await?;
|
||||
let _ = &payload.validate(&account)?;
|
||||
|
||||
match account.mailer_type {
|
||||
MailerType::ImapSmtp => {
|
||||
let flags: Vec<EnvelopeFlag> = payload
|
||||
.tags
|
||||
.clone()
|
||||
.into_iter()
|
||||
.map(|tag| EnvelopeFlag {
|
||||
flag: EmailFlag::Custom,
|
||||
custom: Some(tag),
|
||||
})
|
||||
.collect();
|
||||
let executor = RUST_MAIL_CONTEXT.imap(account_id).await?;
|
||||
|
||||
let uids: Vec<u32> = payload
|
||||
.message_ids
|
||||
.iter()
|
||||
.map(|mid_str| {
|
||||
mid_str
|
||||
.parse::<u32>()
|
||||
.expect("IMAP message ID failed u32 parse after validation.")
|
||||
})
|
||||
.collect();
|
||||
let uid_set = generate_uid_set(uids);
|
||||
|
||||
let mut add_flags: Option<Vec<EnvelopeFlag>> = None;
|
||||
let mut remove_flags: Option<Vec<EnvelopeFlag>> = None;
|
||||
let mut overwrite_flags: Option<Vec<EnvelopeFlag>> = None;
|
||||
|
||||
match payload.action {
|
||||
TagAction::Add => {
|
||||
add_flags = Some(flags);
|
||||
}
|
||||
TagAction::Remove => {
|
||||
remove_flags = Some(flags);
|
||||
}
|
||||
TagAction::Set => {
|
||||
overwrite_flags = Some(flags);
|
||||
}
|
||||
}
|
||||
|
||||
executor
|
||||
.uid_set_flags(
|
||||
&uid_set,
|
||||
&payload.mailbox_name.clone().unwrap(),
|
||||
add_flags,
|
||||
remove_flags,
|
||||
overwrite_flags,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
MailerType::GmailApi => {
|
||||
let labels_map =
|
||||
GmailClient::reverse_label_map(account_id, account.use_proxy, true).await?;
|
||||
let tags_to_process = &payload.tags;
|
||||
let mut target_label_ids: Vec<String> = Vec::with_capacity(tags_to_process.len());
|
||||
for tag_name in tags_to_process {
|
||||
match labels_map.get(tag_name) {
|
||||
Some(label_id) => {
|
||||
target_label_ids.push(label_id.clone());
|
||||
}
|
||||
None => {
|
||||
if let Some(true) = payload.auto_create_tags {
|
||||
let label = GmailClient::create_label(
|
||||
account_id,
|
||||
account.use_proxy,
|
||||
&CreateMailboxRequest {
|
||||
mailbox_name: tag_name.to_string(),
|
||||
parent_name: None,
|
||||
label_color: None,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
target_label_ids.push(label.id);
|
||||
} else {
|
||||
return Err(raise_error!(
|
||||
format!(
|
||||
"Tag/Label name `{}` not found in Gmail labels map. \
|
||||
If you intend to create this label automatically, please ensure the `auto_create_tags` parameter is set to true.",
|
||||
tag_name
|
||||
),
|
||||
ErrorCode::InvalidParameter
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut add_ids: Vec<String> = Vec::new();
|
||||
let mut remove_ids: Vec<String> = Vec::new();
|
||||
match payload.action {
|
||||
TagAction::Add => {
|
||||
add_ids = target_label_ids;
|
||||
}
|
||||
TagAction::Remove => {
|
||||
remove_ids = target_label_ids;
|
||||
}
|
||||
TagAction::Set => {
|
||||
let to_remove_labels: Vec<String> =
|
||||
GmailClient::list_labels(account_id, account.use_proxy)
|
||||
.await?
|
||||
.into_iter()
|
||||
.filter(|label| {
|
||||
label.label_type == "user" && !tags_to_process.contains(&label.name)
|
||||
})
|
||||
.map(|label| label.id)
|
||||
.collect();
|
||||
|
||||
remove_ids = to_remove_labels;
|
||||
add_ids = target_label_ids;
|
||||
}
|
||||
}
|
||||
GmailClient::batch_modify(
|
||||
account_id,
|
||||
account.use_proxy,
|
||||
&payload.message_ids,
|
||||
add_ids,
|
||||
remove_ids,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
MailerType::GraphApi => {
|
||||
let tags_to_operate: HashSet<&String> = payload.tags.iter().collect();
|
||||
|
||||
let existing_categories_map: HashMap<String, Vec<String>> = match payload.action {
|
||||
TagAction::Set => HashMap::new(),
|
||||
_ => {
|
||||
OutlookClient::batch_get_categories(
|
||||
account_id,
|
||||
account.use_proxy,
|
||||
&payload.message_ids,
|
||||
)
|
||||
.await?
|
||||
}
|
||||
};
|
||||
let mut update_instructions: Vec<MessageCategoryUpdate> = Vec::new();
|
||||
match payload.action {
|
||||
TagAction::Add => {
|
||||
for mid in &payload.message_ids {
|
||||
let current_cats = existing_categories_map
|
||||
.get(mid)
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
|
||||
let mut new_categories_set: HashSet<String> =
|
||||
current_cats.into_iter().collect();
|
||||
for tag in &payload.tags {
|
||||
new_categories_set.insert(tag.clone());
|
||||
}
|
||||
|
||||
update_instructions.push(MessageCategoryUpdate {
|
||||
mid: mid.clone(),
|
||||
categories: new_categories_set.into_iter().collect(),
|
||||
});
|
||||
}
|
||||
}
|
||||
TagAction::Remove => {
|
||||
for mid in &payload.message_ids {
|
||||
let current_cats = existing_categories_map
|
||||
.get(mid)
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
|
||||
let mut new_categories: Vec<String> = Vec::new();
|
||||
for cat in current_cats {
|
||||
if !tags_to_operate.contains(&cat) {
|
||||
new_categories.push(cat);
|
||||
}
|
||||
}
|
||||
update_instructions.push(MessageCategoryUpdate {
|
||||
mid: mid.clone(),
|
||||
categories: new_categories,
|
||||
});
|
||||
}
|
||||
}
|
||||
TagAction::Set => {
|
||||
for mid in &payload.message_ids {
|
||||
update_instructions.push(MessageCategoryUpdate {
|
||||
mid: mid.clone(),
|
||||
categories: payload.tags.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
OutlookClient::batch_modify_categories(
|
||||
account_id,
|
||||
account.use_proxy,
|
||||
&update_instructions,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -17,6 +17,8 @@ use crate::modules::message::list::{
|
||||
get_thread_messages, list_messages_in_mailbox, list_threads_in_mailbox,
|
||||
};
|
||||
use crate::modules::message::search::payload::{MessageSearchRequest, UnifiedSearchRequest};
|
||||
use crate::modules::message::tags::tag_messages_impl;
|
||||
use crate::modules::message::tags::BatchTagRequest;
|
||||
use crate::modules::message::transfer::{
|
||||
transfer_messages, MailboxTransferRequest, MessageTransfer,
|
||||
};
|
||||
@@ -109,6 +111,33 @@ impl MessageApi {
|
||||
Ok(modify_flags(account_id, payload.0).await?)
|
||||
}
|
||||
|
||||
/// Batch modifies the custom tags, categories, or keywords on messages.
|
||||
///
|
||||
/// This interface is dedicated to operating on **user-defined labels** and is separate
|
||||
/// from standard system status flags (like Read/Unread).
|
||||
/// It unifies tagging across different email services:
|
||||
/// - **Gmail/Graph API:** Operates on user-defined Label IDs or Category Names.
|
||||
/// - **IMAP/SMTP:** Operates on custom IMAP Keywords (Custom Flags).
|
||||
///
|
||||
/// **Note:** This is a high-level operation designed for user tag management.
|
||||
#[oai(
|
||||
path = "/tag-messages/:account_id",
|
||||
method = "post",
|
||||
operation_id = "tag_messages"
|
||||
)]
|
||||
async fn tag_messages(
|
||||
&self,
|
||||
/// The ID of the account owning the mailbox.
|
||||
account_id: Path<u64>,
|
||||
/// specifying the mailbox, messages, and flags to modify.
|
||||
payload: Json<BatchTagRequest>,
|
||||
context: ClientContext,
|
||||
) -> ApiResult<()> {
|
||||
let account_id = account_id.0;
|
||||
context.require_account_access(account_id)?;
|
||||
Ok(tag_messages_impl(account_id, payload.0).await?)
|
||||
}
|
||||
|
||||
/// Lists messages in a specified mailbox for the given account.
|
||||
#[oai(
|
||||
path = "/list-messages/:account_id",
|
||||
|
||||
Reference in New Issue
Block a user