diff --git a/Cargo.lock b/Cargo.lock index 0ad2f16..f7b2f07 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -348,6 +348,12 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "arrayvec" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b62fc65de8e4e7f52534fb52b0f3ed04746ae267519eef2a83941e8085068b" + [[package]] name = "async-attributes" version = "1.1.2" @@ -635,6 +641,12 @@ dependencies = [ "alloc-stdlib", ] +[[package]] +name = "bufstream" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40e38929add23cdf8a366df9b0e088953150724bcbe5fc330b0d8eb3b328eec8" + [[package]] name = "bumpalo" version = "3.18.1" @@ -679,6 +691,16 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9555578bc9e57714c812a1f84e4fc5b4d21fcb063490c624de019f7464c91268" +[[package]] +name = "charset" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1f927b07c74ba84c7e5fe4db2baeb3e996ab2688992e39ac68ce3220a677c7e" +dependencies = [ + "base64 0.22.1", + "encoding_rs", +] + [[package]] name = "chrono" version = "0.4.41" @@ -1238,10 +1260,14 @@ version = "0.1.0" dependencies = [ "actix-multipart", "actix-web", + "chrono", "dotenv", + "imap", "jmap-client", "log", + "mailparse", "minio", + "native-tls", "serde", "serde_json", "sqlx", @@ -1718,6 +1744,31 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "imap" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c617c55def8c42129e0dd503f11d7ee39d73f5c7e01eff55768b3879ff1d107d" +dependencies = [ + "base64 0.13.1", + "bufstream", + "chrono", + "imap-proto", + "lazy_static", + "native-tls", + "nom 5.1.3", + "regex", +] + +[[package]] +name = "imap-proto" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16a6def1d5ac8975d70b3fd101d57953fe3278ef2ee5d7816cba54b1d1dfc22f" +dependencies = [ + "nom 5.1.3", +] + [[package]] name = "impl-more" version = "0.1.9" @@ -1852,6 +1903,19 @@ dependencies = [ "spin", ] +[[package]] +name = "lexical-core" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6607c62aa161d23d17a9072cc5da0be67cdfc89d3afb1e8d9c842bebc2525ffe" +dependencies = [ + "arrayvec", + "bitflags 1.3.2", + "cfg-if", + "ryu", + "static_assertions", +] + [[package]] name = "libc" version = "0.2.174" @@ -1923,6 +1987,17 @@ dependencies = [ "value-bag", ] +[[package]] +name = "mailparse" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cae768a50835557749599277fc59f7c728118724eb34185e8feb633ef266a32" +dependencies = [ + "charset", + "data-encoding", + "quoted_printable", +] + [[package]] name = "maybe-async" version = "0.2.10" @@ -2053,6 +2128,17 @@ dependencies = [ "tempfile", ] +[[package]] +name = "nom" +version = "5.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08959a387a676302eebf4ddbcbc611da04285579f76f88ee0506c63b1a61dd4b" +dependencies = [ + "lexical-core", + "memchr", + "version_check", +] + [[package]] name = "nom" version = "7.1.3" @@ -2375,6 +2461,12 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "quoted_printable" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a3866219251662ec3b26fc217e3e05bf9c4f84325234dfb96bf0bf840889e49" + [[package]] name = "r-efi" version = "5.3.0" @@ -2915,7 +3007,7 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7bba3a93db0cc4f7bdece8bb09e77e2e785c20bfebf79eb8340ed80708048790" dependencies = [ - "nom", + "nom 7.1.3", "unicode_categories", ] @@ -3122,6 +3214,12 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + [[package]] name = "stringprep" version = "0.1.5" diff --git a/Cargo.toml b/Cargo.toml index bb5d348..49995de 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,6 +10,7 @@ repository = "https://alm.pragmatismo.com.br/generalbots/gbserver" [dependencies] actix-multipart = "0.6" actix-web = "4" +chrono = { version = "0.4", features = ["serde"] } dotenv = "0.15" jmap-client = "0.3.2" log = "0.4" @@ -21,4 +22,7 @@ tempfile = "3" tokio = { version = "1", features = ["full"] } tokio-stream = "0.1.17" tracing = "0.1" -tracing-subscriber = { version = "0.3", features = ["fmt"] } \ No newline at end of file +tracing-subscriber = { version = "0.3", features = ["fmt"] } +imap = "2" +native-tls = "0.2" +mailparse = "0.13" \ No newline at end of file diff --git a/src/prompts/business/send-proposal-v0.bas b/src/prompts/business/send-proposal-v0.bas new file mode 100644 index 0000000..2259735 --- /dev/null +++ b/src/prompts/business/send-proposal-v0.bas @@ -0,0 +1,2 @@ +Based on this ${history}, generate the response for +${to}, signed by ${user} \ No newline at end of file diff --git a/src/prompts/business/send-proposal.bas b/src/prompts/business/send-proposal.bas index e5b7c4f..c542d11 100644 --- a/src/prompts/business/send-proposal.bas +++ b/src/prompts/business/send-proposal.bas @@ -6,20 +6,20 @@ company = QUERY "SELECT Company FROM Opportunities WHERE Id = ${opportunity}" doc = FILL template -# Generate email subject and content based on conversation history +' Generate email subject and content based on conversation history subject = REWRITE "Based on this ${history}, generate a subject for a proposal email to ${company}" contents = REWRITE "Based on this ${history}, and ${subject}, generate the e-mail body for ${to}, signed by ${user}, including key points from our proposal" -# Add proposal to CRM +' Add proposal to CRM CALL "/files/upload", ".gbdrive/Proposals/${company}-proposal.docx", doc CALL "/files/permissions", ".gbdrive/Proposals/${company}-proposal.docx", "sales-team", "edit" -# Record activity in CRM +' Record activity in CRM CALL "/crm/activities/create", opportunity, "email_sent", { "subject": subject, "description": "Proposal sent to " + company, "date": NOW() } -# Send the email +' Send the email CALL "/comm/email/send", to, subject, contents, doc diff --git a/src/scripts/containers/cleaner.sh b/src/scripts/containers/cleaner.sh index 4ce4871..2e71d91 100644 --- a/src/scripts/containers/cleaner.sh +++ b/src/scripts/containers/cleaner.sh @@ -55,6 +55,7 @@ if command -v lxc >/dev/null 2>&1; then rm -rf /tmp/* /var/tmp/* echo 'Cleaning logs...' + rm -rf /opt/gbo/logs/* journalctl --vacuum-time=1d 2>/dev/null || true echo 'Cleaning thumbnail cache...' diff --git a/src/services.rs b/src/services.rs index 5c6ea42..0ef8ec3 100644 --- a/src/services.rs +++ b/src/services.rs @@ -1,4 +1,5 @@ pub mod config; pub mod file; pub mod state; -pub mod email; \ No newline at end of file +pub mod email; +pub mod llm; \ No newline at end of file diff --git a/src/services/config.rs b/src/services/config.rs index 22787e6..c356343 100644 --- a/src/services/config.rs +++ b/src/services/config.rs @@ -5,11 +5,12 @@ pub struct AppConfig { pub minio: MinioConfig, pub server: ServerConfig, pub database: DatabaseConfig, + pub email: EmailConfig, + pub ai: AIConfig, } #[derive(Clone)] pub struct DatabaseConfig { - pub username: String, pub password: String, pub server: String, @@ -32,6 +33,27 @@ pub struct ServerConfig { pub port: u16, } +#[derive(Clone)] +pub struct EmailConfig { + pub from: String, + pub server: String, + pub port: u16, + pub username: String, + pub password: String, + pub reject_unauthorized: bool, +} + +#[derive(Clone)] +pub struct AIConfig { + pub image_model: String, + pub embedding_model: String, + pub instance: String, + pub key: String, + pub llm_model: String, + pub version: String, + pub endpoint: String, +} + impl AppConfig { pub fn database_url(&self) -> String { format!( @@ -66,6 +88,32 @@ impl AppConfig { .unwrap_or(false), bucket: env::var("DRIVE_ORG_PREFIX").unwrap_or_else(|_| "".to_string()), }; + + let email = EmailConfig { + from: env::var("EMAIL_FROM").expect("EMAIL_FROM not set"), + server: env::var("EMAIL_SERVER").expect("EMAIL_SERVER not set"), + port: env::var("EMAIL_PORT") + .expect("EMAIL_PORT not set") + .parse() + .expect("EMAIL_PORT must be a number"), + username: env::var("EMAIL_USER").expect("EMAIL_USER not set"), + password: env::var("EMAIL_PASS").expect("EMAIL_PASS not set"), + reject_unauthorized: env::var("EMAIL_REJECT_UNAUTHORIZED") + .unwrap_or_else(|_| "false".to_string()) + .parse() + .unwrap_or(false), + }; + + let ai = AIConfig { + image_model: env::var("AI_IMAGE_MODEL").expect("AI_IMAGE_MODEL not set"), + embedding_model: env::var("AI_EMBEDDING_MODEL").expect("AI_EMBEDDING_MODEL not set"), + instance: env::var("AI_INSTANCE").expect("AI_INSTANCE not set"), + key: env::var("AI_KEY").expect("AI_KEY not set"), + llm_model: env::var("AI_LLM_MODEL").expect("AI_LLM_MODEL not set"), + version: env::var("AI_VERSION").expect("AI_VERSION not set"), + endpoint: env::var("AI_ENDPOINT").expect("AI_ENDPOINT not set"), + }; + AppConfig { minio, server: ServerConfig { @@ -76,6 +124,8 @@ impl AppConfig { .unwrap_or(8080), }, database, + email, + ai, } } -} +} \ No newline at end of file diff --git a/src/services/email.rs b/src/services/email.rs index 7a4d13d..1553f01 100644 --- a/src/services/email.rs +++ b/src/services/email.rs @@ -1,61 +1,288 @@ -use actix_web::web; -use actix_web::{http::header::ContentType, HttpResponse}; +use std::str::FromStr; + +use actix_web::{error::ErrorInternalServerError, http::header::ContentType, web, HttpResponse}; use jmap_client::{ - client::Client, - core::query::Filter, - email::{self, Property}, - mailbox::{self, Role}, + client::Client, core::query::Filter, email, + identity::Property, mailbox::{self, Role}, + email::Property as EmailProperty }; +use serde::Serialize; + +#[derive(Debug, Serialize)] +pub struct EmailResponse { + pub id: String, + pub name: String, + pub email: String, + pub subject: String, + pub text: String, +} use crate::services::state::AppState; -#[actix_web::post("/emails/list")] -pub async fn list_emails() -> Result>, actix_web::Error> { - // 1. Authenticate with JMAP server - let client = Client::new() - .credentials(("test@", "")) - .connect("https://mail/jmap/") - .await - .map_err(|e| actix_web::error::ErrorInternalServerError(e))?; +async fn create_jmap_client( + state: &web::Data, +) -> Result<(Client, String, String, String), actix_web::Error> { + let config = state + .config + .as_ref() + .ok_or_else(|| actix_web::error::ErrorInternalServerError("Configuration not available"))?; + let client = Client::new() + .credentials(( + config.email.username.as_ref(), + config.email.password.as_ref(), + )) + .connect(&config.email.server) + .await + .map_err(|e| { + actix_web::error::ErrorInternalServerError(format!("JMAP connection error: {}", e)) + })?; + + // 2. Get account ID and email + let session = client.session(); + let (account_id, email) = session + .accounts() + .find_map(|account_id| { + let account = session.account(account_id).unwrap(); + Some((account_id.to_string(), account.name().to_string())) + }) + .unwrap(); + + let identity = client + .identity_get("default", Some(vec![Property::Id, Property::Email])) + .await + .map_err(|e| { + actix_web::error::ErrorInternalServerError(format!("JMAP connection error: {}", e)) + })?.unwrap(); + + let identity_id = identity.id().unwrap(); + + println!("Account ID: {}", account_id); + println!("Email address: {}", email); + println!("IdentityID: {}", identity_id); + + Ok((client, account_id, email, String::from_str(identity_id)?)) +} + +#[actix_web::post("/emails/list")] +pub async fn list_emails( + state: web::Data, +) -> Result>, actix_web::Error> { + let (client, account_id, email, identity_id) = create_jmap_client(&state).await?; + + // Get inbox mailbox let inbox_id = client .mailbox_query( mailbox::query::Filter::role(Role::Inbox).into(), None::>, ) .await - .map_err(|e| actix_web::error::ErrorInternalServerError(e))? + .map_err(|e| { + actix_web::error::ErrorInternalServerError(format!("Failed to query inbox: {}", e)) + })? .take_ids() - .pop() - .ok_or_else(|| actix_web::error::ErrorInternalServerError("No inbox found"))?; + .first() + .ok_or_else(|| actix_web::error::ErrorInternalServerError("Inbox not found"))? + .clone(); - let mut emails = client + // Query emails in inbox + let email_ids = client .email_query( - Filter::and([email::query::Filter::in_mailbox(inbox_id)]).into(), - [email::query::Comparator::from()].into(), + Filter::and([email::query::Filter::in_mailbox(&inbox_id)]).into(), + None::>, ) .await - .map_err(|e| actix_web::error::ErrorInternalServerError(e))?; + .map_err(|e| { + actix_web::error::ErrorInternalServerError(format!("Failed to query emails: {}", e)) + })? + .take_ids(); - let email_ids = emails.take_ids(); let mut email_list = Vec::new(); - for email_id in email_ids { - if let Some(email) = client + // Fetch email details + let email = client .email_get( &email_id, - [Property::Subject, Property::Preview, Property::Keywords].into(), + [ + EmailProperty::Id, + EmailProperty::Subject, + EmailProperty::From, + EmailProperty::TextBody, + EmailProperty::Preview, + ] + .into(), ) .await - .map_err(|e| actix_web::error::ErrorInternalServerError(e))? - { - email_list.push(email); - } + .map_err(|e| { + actix_web::error::ErrorInternalServerError(format!("Failed to get emails: {}", e)) + })? + .unwrap(); + + let from = email.from().unwrap().first(); + let (name, email_addr) = if let Some(addr) = from { + ( + addr.name().unwrap_or("Unknown").to_string(), + addr.email().to_string(), + ) + } else { + ("Unknown".to_string(), "unknown@example.com".to_string()) + }; + + let text = email.preview().unwrap_or_default().to_string(); + + email_list.push(EmailResponse { + id: email.id().unwrap().to_string(), + name, + email: email_addr, + subject: email.subject().unwrap_or_default().to_string(), + text, + }); } Ok(web::Json(email_list)) } +#[actix_web::post("/emails/suggest-answer/{email_id}")] +pub async fn suggest_answer( + path: web::Path, + state: web::Data, +) -> Result { + let email_id = path.into_inner(); + let (client, account_id, email, identity_id) = create_jmap_client(&state).await?; + + // Fetch the specific email + let email = client + .email_get( + &email_id, + [ + EmailProperty::Id, + EmailProperty::Subject, + EmailProperty::From, + EmailProperty::TextBody, + EmailProperty::Preview, + ] + .into(), + ) + .await + .map_err(|e| { + actix_web::error::ErrorInternalServerError(format!("Failed to get email: {}", e)) + })? + .into_iter() + .next() + .ok_or_else(|| actix_web::error::ErrorNotFound("Email not found"))?; + + let from = email.from().unwrap().first(); + let sender_info = if let Some(addr) = from { + format!("{} <{}>", addr.name().unwrap_or("Unknown"), addr.email()) + } else { + "Unknown sender".to_string() + }; + + let subject = email.subject().unwrap_or_default(); + let body_text = email.preview().unwrap_or_default(); + + let response = serde_json::json!({ + "suggested_response": "Thank you for your email. I will review this and get back to you shortly.", + "prompt": format!( + "Email from: {}\nSubject: {}\n\nBody:\n{}\n\n---\n\nPlease draft a professional response to this email.", + sender_info, subject, body_text + ) + }); + + Ok(HttpResponse::Ok().json(response)) +} + +#[actix_web::post("/emails/archive/{email_id}")] +pub async fn archive_email( + path: web::Path, + state: web::Data, +) -> Result { + let email_id = path.into_inner(); + let (client, account_id, email, identity_id) = create_jmap_client(&state).await?; + + // Get Archive mailbox (or create if it doesn't exist) + let archive_id = match client + .mailbox_query( + mailbox::query::Filter::name("Archive").into(), + None::>, + ) + .await + { + Ok(mut result) => { + let ids = result.take_ids(); + if let Some(id) = ids.first() { + id.clone() + } else { + // Create Archive mailbox if it doesn't exist + client + .mailbox_create("Archive", None::, Role::Archive) + .await + .map_err(|e| { + actix_web::error::ErrorInternalServerError(format!( + "Failed to create archive mailbox: {}", + e + )) + })? + .take_id() + } + } + Err(e) => { + return Err(actix_web::error::ErrorInternalServerError(format!( + "Failed to query mailboxes: {}", + e + ))); + } + }; + + // Move email to Archive mailbox + client + .email_set_mailboxes(&email_id, [&archive_id]) + .await + .map_err(|e| { + actix_web::error::ErrorInternalServerError(format!("Failed to archive email: {}", e)) + })?; + + Ok(HttpResponse::Ok().json(serde_json::json!({ + "message": "Email archived successfully", + "email_id": email_id, + "archive_mailbox_id": archive_id + }))) +} + +#[actix_web::post("/emails/send")] +pub async fn send_email( + payload: web::Json<(String, String, String)>, + state: web::Data, +) -> Result { + // Destructure the tuple into individual components + let (to, subject, body) = payload.into_inner(); + + println!("To: {}", to); + println!("Subject: {}", subject); + println!("Body: {}", body); + + let (client, account_id, email, identity_id) = create_jmap_client(&state).await?; + + let email_submission = client + .email_submission_create("111", account_id) + .await + .map_err(|e| { + actix_web::error::ErrorInternalServerError(format!("Failed to create email: {}", e)) + })?; + + let email_id = email_submission.email_id().unwrap(); + println!("Email-ID: {}", email_id); + + client + .email_submission_create(email_id, identity_id) + .await + .map_err(|e| { + actix_web::error::ErrorInternalServerError(format!("Failed to send email: {}", e)) + })?; + + Ok(HttpResponse::Ok().finish()) +} + #[actix_web::get("/campaigns/{campaign_id}/click/{email}")] pub async fn save_click( path: web::Path<(String, String)>, diff --git a/src/services/llm.rs b/src/services/llm.rs new file mode 100644 index 0000000..27c9a05 --- /dev/null +++ b/src/services/llm.rs @@ -0,0 +1,15 @@ +use actix_web::http::Error; + + +// You'll need to add this to your AppState +pub struct LLM { + // Your AI client implementation +} + +impl LLM { + pub async fn generate_response(&self, prompt: &str) -> Result { + // Implement your AI service call here + Ok("Suggested response".to_string()) + } +} +