diff --git a/protos/rustmailer.proto b/protos/rustmailer.proto index 9ac9811..a61e032 100644 --- a/protos/rustmailer.proto +++ b/protos/rustmailer.proto @@ -1004,22 +1004,30 @@ message ByteResponse { message AppendReplyToDraftRequest { // The ID of the email account uint64 account_id = 1; - // The name of the mailbox containing the original message. - // This is used to locate the source message that is being replied to. + // The name of the mailbox or label containing the original message + // - For IMAP accounts, this is the mailbox name where the source message resides. + // - For Gmail API accounts, this refers to the label name associated with the source message. string mailbox_name = 2; - // The UID of the message being replied to - uint32 uid = 3; + // The UID of the message being replied to (IMAP accounts only) + // For IMAP accounts, this identifies the specific message in the mailbox. + // For Gmail API accounts, this field is ignored. + optional uint32 uid = 3; // Optional preview text for the reply email optional string preview = 4; // Optional plain text body of the reply email optional string text = 5; // Optional HTML body of the reply email optional string html = 6; - // The draft folder path, e.g. "[Gmail]/Drafts" - string draft_folder_path = 7; + // The path of the folder used to store drafts (IMAP accounts only) + // For example: "[Gmail]/Drafts". + // For Gmail API accounts, this field is ignored. + optional string draft_folder_path = 7; + // The Gmail API message ID (Gmail API accounts only) + // This is the `id` returned by `list messages` and used by `get message`. + // For IMAP accounts, this field is ignored. + optional string mid = 8; } - // MessageService provides APIs for interacting with email messages. service MessageService { // Moves messages from one mailbox to another. diff --git a/src/modules/cache/vendor/gmail/sync/client.rs b/src/modules/cache/vendor/gmail/sync/client.rs index 9be672c..fbcb133 100644 --- a/src/modules/cache/vendor/gmail/sync/client.rs +++ b/src/modules/cache/vendor/gmail/sync/client.rs @@ -149,4 +149,16 @@ impl GmailClient { ), ErrorCode::InternalError))?; Ok(list) } + + pub async fn create_draft( + account_id: u64, + use_proxy: Option, + body: serde_json::Value, + ) -> RustMailerResult { + let url = "https://gmail.googleapis.com/gmail/v1/users/me/drafts"; + let client = HttpClient::new(use_proxy).await?; + let access_token = Self::get_access_token(account_id).await?; + let value = client.post(url, &access_token, &body).await?; + Ok(value) + } } diff --git a/src/modules/cache/vendor/tests.rs b/src/modules/cache/vendor/tests.rs index 4a84e25..4b57633 100644 --- a/src/modules/cache/vendor/tests.rs +++ b/src/modules/cache/vendor/tests.rs @@ -2,12 +2,17 @@ // Licensed under RustMailer License Agreement v1.0 // Unauthorized copying, modification, or distribution is prohibited. +use mail_send::{ + mail_builder::{headers::address::Address, MessageBuilder}, + smtp::message::IntoMessage, +}; use poem_grpc::{ClientConfig, CompressionEncoding}; use reqwest::header::{AUTHORIZATION, CONTENT_TYPE}; -use serde_json::Value; -use std::time::Duration; +use serde_json::{json, Value}; +use std::{borrow::Cow, time::Duration}; use crate::{ + base64_encode, modules::{ cache::{ imap::v2::EmailEnvelopeV3, @@ -38,7 +43,7 @@ async fn access_token() -> String { grpc_client.set_send_compressed(CompressionEncoding::GZIP); let request = GetOAuth2TokensRequest { - account_id: 1908057970788951, + account_id: 4391092875701825, }; let mut request = poem_grpc::Request::new(request); @@ -202,3 +207,66 @@ async fn test5() { println!("{:?}", addr); } } + +#[tokio::test] +async fn test6() { + let access_token = access_token().await; + let url = "https://gmail.googleapis.com/gmail/v1/users/me/drafts"; + let mut builder = reqwest::ClientBuilder::new() + .user_agent(rustmailer_version!()) + .timeout(Duration::from_secs(10)) + .connect_timeout(Duration::from_secs(10)); + + let proxy_obj = reqwest::Proxy::all("socks5://127.0.0.1:22308").unwrap(); + builder = builder + .redirect(reqwest::redirect::Policy::none()) + .proxy(proxy_obj); + let client = builder.build().unwrap(); + + let from = Address::new_address( + Some(Cow::Owned("rustmailer".to_string())), + Cow::Owned("rustmailer.git@gmail.com".to_string()), + ); + let to = Address::new_address( + Some(Cow::Owned("noreply".to_string())), + Cow::Owned("noreply@medium.com".to_string()), + ); + let subject = "Re: 👋 Welcome to Medium".to_string(); + let mut builder = MessageBuilder::new() + .from(from) + .to(Address::from(to.clone())) + .subject(subject.clone()); + + builder = builder.in_reply_to("5JgiBu1_TSC_RIro8-xLWg@geopod-ismtpd-4".to_string()); + let references = vec!["5JgiBu1_TSC_RIro8-xLWg@geopod-ismtpd-4".to_string()]; + builder = builder.references(references); + builder = builder.text_body("wowwowwowwowwowwowwowwowwowwowwowwow"); + + let message = builder.into_message().unwrap(); + + let raw_encoded = base64_encode!(&message.body); + + let body = json!({ + "message": { + "threadId": "19720f0b9bd3822c", + "raw": raw_encoded + } + }); + + let res = client + .post(url) + .header(AUTHORIZATION, format!("Bearer {}", access_token)) + .header(CONTENT_TYPE, "application/json") + .json(&body) + .send() + .await + .unwrap(); + + if res.status().is_success() { + let body: Value = res.json().await.unwrap(); + let json = serde_json::to_string_pretty(&body).unwrap(); + println!("Response = {}", json); + } else { + eprintln!("Error: {} - {:?}", res.status(), res.text().await.unwrap()); + } +} diff --git a/src/modules/database/tests.rs b/src/modules/database/tests.rs index 63ea023..d384490 100644 --- a/src/modules/database/tests.rs +++ b/src/modules/database/tests.rs @@ -135,13 +135,9 @@ fn test7() { .unwrap(); // println!("{:#?}", entities); - let history_ids: Vec = entities + let envelopes: Vec = entities .into_iter() - .filter(|e| e.label_name == "INBOX") - .map(|e| e.history_id) + .filter(|e| e.id == "19720f0b9bd3822c") .collect(); - println!("{}", history_ids.len()); - - let max_id = max_history_id(&history_ids); - println!("{:#?}", max_id); + println!("{:#?}", envelopes); } diff --git a/src/modules/grpc/service/message/from.rs b/src/modules/grpc/service/message/from.rs index 017cb71..60ec5e4 100644 --- a/src/modules/grpc/service/message/from.rs +++ b/src/modules/grpc/service/message/from.rs @@ -564,6 +564,7 @@ impl From for AppendReplyToDraftRequ text: value.text, html: value.html, draft_folder_path: value.draft_folder_path, + mid: value.mid, } } } diff --git a/src/modules/grpc/tests.rs b/src/modules/grpc/tests.rs index 91595e9..acc6c86 100644 --- a/src/modules/grpc/tests.rs +++ b/src/modules/grpc/tests.rs @@ -186,11 +186,12 @@ async fn test6() { let request = AppendReplyToDraftRequest { account_id: 6637484689546669, mailbox_name: "INBOX".into(), - uid: 395, + uid: Some(395), preview: None, text: Some("hello world.".into()), html: None, - draft_folder_path: "[Gmail]/Drafts".into(), + draft_folder_path: Some("[Gmail]/Drafts".into()), + mid: None, }; let mut request = poem_grpc::Request::new(request); @@ -230,3 +231,34 @@ async fn test7() { .await .unwrap(); } + +#[tokio::test] +async fn test8() { + RustMailerTls::initialize().await.unwrap(); + + let cfg = ClientConfig::builder() + .uri("http://localhost:16630") + .build() + .unwrap(); + let mut grpc_client = MessageServiceClient::new(cfg); + grpc_client.set_accept_compressed([CompressionEncoding::GZIP]); + grpc_client.set_send_compressed(CompressionEncoding::GZIP); + + let request = AppendReplyToDraftRequest { + account_id: 4391092875701825, + mailbox_name: "INBOX".into(), + uid: None, + preview: None, + text: Some("hello world.".into()), + html: None, + draft_folder_path: None, + mid: Some("1970d297da3c2dd2".into()), + }; + + let mut request = poem_grpc::Request::new(request); + request.metadata_mut().insert( + AUTHORIZATION, + format!("Bearer {}", "2mY4irNCahQXeSarHYje1P1W"), + ); + grpc_client.append_reply_to_draft(request).await.unwrap(); +} diff --git a/src/modules/hook/http/mod.rs b/src/modules/hook/http/mod.rs index e5fdb88..b77dec7 100644 --- a/src/modules/hook/http/mod.rs +++ b/src/modules/hook/http/mod.rs @@ -5,6 +5,7 @@ use dashmap::DashMap; use http::header::{AUTHORIZATION, CONTENT_TYPE}; use http::StatusCode; +use serde::Serialize; use tracing::error; use crate::modules::error::code::ErrorCode; @@ -168,4 +169,53 @@ impl HttpClient { )) } } + + /// Wrapper around the Gmail API `POST` request. + pub async fn post( + &self, + url: &str, + access_token: &str, + body: &T, + ) -> RustMailerResult { + let res = self + .client + .post(url) + .header(AUTHORIZATION, format!("Bearer {}", access_token)) + .header(CONTENT_TYPE, "application/json") + .json(body) + .send() + .await + .map_err(|e| { + raise_error!( + format!("Request failed: {:#?}", e), + ErrorCode::InternalError + ) + })?; + + if res.status().is_success() { + let json: serde_json::Value = res.json().await.map_err(|e| { + raise_error!( + format!("Failed to parse response: {:#?}", e), + ErrorCode::InternalError + ) + })?; + Ok(json) + } else { + let status = res.status(); + let text = res.text().await.map_err(|e| { + raise_error!( + format!("Failed to read error response: {:#?}", e), + ErrorCode::InternalError + ) + })?; + // Return the error with status and response text for more context + Err(raise_error!( + format!( + "Gmail API call to {} failed with status {}: {}", + url, status, text + ), + ErrorCode::GmailApiCallFailed + )) + } + } } diff --git a/src/modules/message/append.rs b/src/modules/message/append.rs index 166c20c..93db062 100644 --- a/src/modules/message/append.rs +++ b/src/modules/message/append.rs @@ -10,53 +10,139 @@ use mail_send::{ }; use poem_openapi::Object; use serde::{Deserialize, Serialize}; +use serde_json::json; use crate::{ - encode_mailbox_name, + base64_encode, encode_mailbox_name, modules::{ - account::v2::AccountV2, + account::{entity::MailerType, v2::AccountV2}, + cache::vendor::gmail::sync::{ + client::GmailClient, envelope::GmailEnvelope, labels::GmailLabels, + }, context::executors::RUST_MAIL_CONTEXT, error::{code::ErrorCode, RustMailerResult}, - smtp::request::{reply::apply_references, EmailHandler}, + smtp::request::{ + reply::{apply_references, apply_references2}, + EmailHandler, + }, }, raise_error, }; #[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize, Object)] pub struct AppendReplyToDraftRequest { - /// The name of the mailbox containing the original message. + /// The name of the mailbox or label containing the original message. /// - /// This is used to locate the source message that is being replied to. + /// - For IMAP accounts, this is the mailbox name where the source message resides. + /// - For Gmail API accounts, this refers to the label name associated with the source message. + /// This is used to locate the message being replied to. pub mailbox_name: String, - /// The UID of the message being replied to. + /// The UID of the message being replied to (IMAP accounts only). /// - /// This identifies the specific message in the mailbox. - pub uid: u32, + /// For IMAP accounts, this identifies the specific message in the mailbox. + /// For Gmail API accounts, this field is ignored. + pub uid: Option, + /// The Gmail API message ID (Gmail API accounts only), sourced from [`EmailEnvelopeV3::mid`]. + /// + /// This is the `id` returned by `list messages` and used by `get message`. + /// For IMAP accounts, this field is ignored. + pub mid: Option, /// A preview text for the reply email. /// /// This optional field provides a short summary or preview of the reply content. + #[oai(validator(min_length = "1", max_length = "200"))] pub preview: Option, /// The plain text body of the reply email. /// /// This field is optional and can be used to provide plain text content. + #[oai(validator(min_length = "1", max_length = "10000"))] pub text: Option, /// The HTML body of the reply email. /// /// This field is optional and can be used to provide HTML content. + #[oai(validator(min_length = "1", max_length = "50000"))] pub html: Option, - // For example: "[Gmail]/Drafts" - // This can be obtained from the `name` field of the mailbox via the list-mailboxes endpoint. - pub draft_folder_path: String, + /// The path of the folder used to store drafts (IMAP accounts only). + /// + /// For example: "[Gmail]/Drafts". + /// This can be obtained from the `name` field of the mailbox via the list-mailboxes endpoint. + /// For Gmail API accounts, this field is ignored. + pub draft_folder_path: Option, } impl AppendReplyToDraftRequest { + fn validate(&self, is_gmail_api: bool) -> RustMailerResult<()> { + if self.mailbox_name.trim().is_empty() { + return Err(raise_error!( + "mailbox_name cannot be empty".into(), + ErrorCode::InvalidParameter + )); + } + + if is_gmail_api { + // Gmail API account: mid required + if self.mid.as_ref().map(|s| s.is_empty()).unwrap_or(true) { + return Err(raise_error!( + "mid is required for Gmail API accounts".into(), + ErrorCode::InvalidParameter + )); + } + } else { + // IMAP account: uid and draft_folder_path required + if self.uid.is_none() { + return Err(raise_error!( + "uid is required for IMAP accounts".into(), + ErrorCode::InvalidParameter + )); + } + if self + .draft_folder_path + .as_ref() + .map(|s| s.is_empty()) + .unwrap_or(true) + { + return Err(raise_error!( + "draft_folder_path cannot be empty for IMAP accounts".into(), + ErrorCode::InvalidParameter + )); + } + } + Ok(()) + } + pub async fn append_reply_to_draft(&self, account_id: u64) -> RustMailerResult<()> { let account = AccountV2::check_account_active(account_id, false).await?; - let envelope = EmailHandler::get_envelope(&account, &self.mailbox_name, self.uid).await?; + self.validate(matches!(account.mailer_type, MailerType::GmailApi))?; + + match account.mailer_type { + MailerType::ImapSmtp => self.append_reply_to_draft_imap(&account).await?, + MailerType::GmailApi => { + self.append_reply_to_draft_gmail(&account, account_id) + .await? + } + } + + Ok(()) + } + + async fn append_reply_to_draft_imap(&self, account: &AccountV2) -> RustMailerResult<()> { + let envelope = EmailHandler::get_envelope( + account, + &self.mailbox_name, + self.uid.ok_or_else(|| { + raise_error!( + "uid is missing but required for IMAP accounts".into(), + ErrorCode::InternalError + ) + })?, + ) + .await?; + let from = Address::new_address( account.name.as_ref().map(|n| Cow::Owned(n.to_string())), Cow::Owned(account.email.clone()), ); + let to = match &envelope.reply_to { Some(reply_to) if !reply_to.is_empty() => reply_to.clone(), _ => envelope @@ -70,11 +156,12 @@ impl AppendReplyToDraftRequest { ) })?, }; + let subject = format!("Re: {}", envelope.subject.as_deref().unwrap_or("")); let mut builder = MessageBuilder::new() .from(from) .to(Address::from(to.clone())) - .subject(subject.clone()); + .subject(subject); builder = apply_references(builder, &envelope)?; builder = self.apply_content(builder)?; let message = builder.into_message().map_err(|e| { @@ -83,9 +170,102 @@ impl AppendReplyToDraftRequest { ErrorCode::InternalError ) })?; - let executor = RUST_MAIL_CONTEXT.imap(account_id).await?; - let drafts = encode_mailbox_name!(&self.draft_folder_path); - executor.append(drafts, None, None, message.body).await + + let executor = RUST_MAIL_CONTEXT.imap(account.id).await?; + let drafts = + encode_mailbox_name!(&self.draft_folder_path.as_ref().ok_or_else(|| raise_error!( + "draft_folder_path is missing but required for IMAP accounts".into(), + ErrorCode::InternalError + ))?); + executor.append(drafts, None, None, message.body).await?; + Ok(()) + } + + async fn append_reply_to_draft_gmail( + &self, + account: &AccountV2, + account_id: u64, + ) -> RustMailerResult<()> { + let labels = GmailLabels::list_all(account_id).await?; + let target_label = labels + .iter() + .find(|label| label.name == self.mailbox_name) + .ok_or_else(|| { + raise_error!( + format!( + "Label '{}' not found for account {}", + self.mailbox_name, account_id + ), + ErrorCode::MailBoxNotCached + ) + })?; + + let envelope = GmailEnvelope::find( + account_id, + target_label.id, + &self.mid.as_ref().ok_or_else(|| { + raise_error!( + "mid is missing but required for Gmail API accounts".into(), + ErrorCode::InternalError + ) + })?, + ) + .await? + .ok_or_else(|| { + raise_error!( + format!( + "Gmail message with id '{}' not found in label '{}' for account {}", + self.mid.as_ref().unwrap(), + target_label.name, + account_id + ), + ErrorCode::ResourceNotFound + ) + })?; + + let from = Address::new_address( + account.name.as_ref().map(|n| Cow::Owned(n.to_string())), + Cow::Owned(account.email.clone()), + ); + + let to = match &envelope.reply_to { + Some(reply_to) if !reply_to.is_empty() => reply_to.clone(), + _ => envelope + .from + .clone() + .map(|from| vec![from]) + .ok_or_else(|| { + raise_error!( + "Invalid email envelope: missing both 'reply_to' and 'from'".into(), + ErrorCode::InvalidParameter + ) + })?, + }; + + let subject = format!("Re: {}", envelope.subject.as_deref().unwrap_or("")); + let mut builder = MessageBuilder::new() + .from(from) + .to(Address::from(to.clone())) + .subject(subject); + builder = apply_references2(builder, &envelope)?; + builder = self.apply_content(builder)?; + let message = builder.into_message().map_err(|e| { + raise_error!( + format!("Failed to build message: {}", e), + ErrorCode::InternalError + ) + })?; + + let raw_encoded = base64_encode!(&message.body); + let body = json!({ + "message": { + "threadId": envelope.gmail_thread_id, + "raw": raw_encoded + } + }); + + GmailClient::create_draft(account_id, account.use_proxy, body).await?; + Ok(()) } fn apply_content( diff --git a/src/modules/smtp/request/reply.rs b/src/modules/smtp/request/reply.rs index 6c452be..6ecc159 100644 --- a/src/modules/smtp/request/reply.rs +++ b/src/modules/smtp/request/reply.rs @@ -5,7 +5,7 @@ use crate::{ modules::{ account::v2::AccountV2, - cache::imap::v2::EmailEnvelopeV3, + cache::{imap::v2::EmailEnvelopeV3, vendor::gmail::sync::envelope::GmailEnvelope}, error::{code::ErrorCode, RustMailerResult}, smtp::{ composer::BodyComposer, @@ -356,3 +356,22 @@ pub fn apply_references( } Ok(builder.references(references)) } + +pub fn apply_references2( + builder: MessageBuilder<'static>, + envelope: &GmailEnvelope, +) -> RustMailerResult> { + let builder = if let Some(message_id) = &envelope.message_id { + builder.in_reply_to(message_id.clone()) + } else { + builder + }; + + let mut references = envelope.references.clone().unwrap_or_default(); + if let Some(message_id) = &envelope.message_id { + if !references.contains(message_id) { + references.push(message_id.clone()); + } + } + Ok(builder.references(references)) +}