From d1cb6b758cbed905c1415f135a0327a20e51aeec Mon Sep 17 00:00:00 2001 From: "Rodrigo Rodriguez (Pragmatismo)" Date: Sun, 15 Mar 2026 20:00:06 -0300 Subject: [PATCH] Fix LXD container mode: PATH, socket proxy, exec --- .product | 2 +- .../2026-03-15-email-crm-campaigns/down.sql | 9 + .../2026-03-15-email-crm-campaigns/up.sql | 114 +++++++++++ src/core/package_manager/facade.rs | 40 ++-- src/core/shared/schema/email_integration.rs | 61 ++++++ src/core/shared/schema/mod.rs | 4 + src/email/flags.rs | 93 +++++++++ src/email/integration.rs | 192 ++++++++++++++++++ src/email/integration_types.rs | 56 +++++ src/email/mod.rs | 58 ++++++ src/email/nudges.rs | 57 ++++++ src/email/snooze.rs | 137 +++++++++++++ src/security/command_guard.rs | 3 + 13 files changed, 800 insertions(+), 26 deletions(-) create mode 100644 migrations/2026-03-15-email-crm-campaigns/down.sql create mode 100644 migrations/2026-03-15-email-crm-campaigns/up.sql create mode 100644 src/core/shared/schema/email_integration.rs create mode 100644 src/email/flags.rs create mode 100644 src/email/integration.rs create mode 100644 src/email/integration_types.rs create mode 100644 src/email/nudges.rs create mode 100644 src/email/snooze.rs diff --git a/.product b/.product index 0011d299..2a8a1518 100644 --- a/.product +++ b/.product @@ -12,7 +12,7 @@ name=General Bots # Available apps: chat, mail, calendar, drive, tasks, docs, paper, sheet, slides, # meet, research, sources, analytics, admin, monitoring, settings # Only listed apps will be visible in the UI and have their APIs enabled. -apps=chat,people,drive,tasks,sources,settings +apps=chat,people,drive,tasks,sources,settings,mail,crm,campaigns,meet,attendance # Search mechanism enabled # Controls whether the omnibox/search toolbar is displayed in the suite diff --git a/migrations/2026-03-15-email-crm-campaigns/down.sql b/migrations/2026-03-15-email-crm-campaigns/down.sql new file mode 100644 index 00000000..5ab08d23 --- /dev/null +++ b/migrations/2026-03-15-email-crm-campaigns/down.sql @@ -0,0 +1,9 @@ +DROP TABLE IF EXISTS email_offline_queue CASCADE; +DROP TABLE IF EXISTS email_campaign_links CASCADE; +DROP TABLE IF EXISTS email_crm_links CASCADE; +DROP TABLE IF EXISTS feature_flags CASCADE; +DROP TABLE IF EXISTS email_nudges CASCADE; +DROP TABLE IF EXISTS email_flags CASCADE; +DROP TABLE IF EXISTS email_snooze CASCADE; +DROP TABLE IF EXISTS email_accounts CASCADE; +DROP TABLE IF EXISTS emails CASCADE; diff --git a/migrations/2026-03-15-email-crm-campaigns/up.sql b/migrations/2026-03-15-email-crm-campaigns/up.sql new file mode 100644 index 00000000..cd31a7ee --- /dev/null +++ b/migrations/2026-03-15-email-crm-campaigns/up.sql @@ -0,0 +1,114 @@ +-- Email tables +CREATE TABLE IF NOT EXISTS emails ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL, + account_id UUID, + folder VARCHAR(50) NOT NULL DEFAULT 'inbox', + from_address VARCHAR(255) NOT NULL, + to_address TEXT NOT NULL, + cc_address TEXT, + bcc_address TEXT, + subject TEXT, + body TEXT, + html_body TEXT, + is_read BOOLEAN DEFAULT FALSE, + is_starred BOOLEAN DEFAULT FALSE, + is_flagged BOOLEAN DEFAULT FALSE, + thread_id UUID, + in_reply_to UUID, + message_id VARCHAR(255), + ai_category VARCHAR(50), + ai_confidence FLOAT, + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW() +); + +CREATE INDEX idx_emails_user_folder ON emails(user_id, folder); +CREATE INDEX idx_emails_thread ON emails(thread_id); +CREATE INDEX idx_emails_from ON emails(from_address); + +-- Email accounts +CREATE TABLE IF NOT EXISTS email_accounts ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL, + name VARCHAR(255) NOT NULL, + email VARCHAR(255) NOT NULL, + imap_server VARCHAR(255), + imap_port INTEGER DEFAULT 993, + smtp_server VARCHAR(255), + smtp_port INTEGER DEFAULT 587, + username VARCHAR(255), + password_encrypted TEXT, + use_ssl BOOLEAN DEFAULT TRUE, + is_active BOOLEAN DEFAULT TRUE, + created_at TIMESTAMP DEFAULT NOW() +); + +-- Email snooze +CREATE TABLE IF NOT EXISTS email_snooze ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + email_id UUID NOT NULL REFERENCES emails(id) ON DELETE CASCADE, + snooze_until TIMESTAMP NOT NULL, + created_at TIMESTAMP DEFAULT NOW() +); + +CREATE INDEX idx_email_snooze_until ON email_snooze(snooze_until); + +-- Email flags (follow-up) +CREATE TABLE IF NOT EXISTS email_flags ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + email_id UUID NOT NULL REFERENCES emails(id) ON DELETE CASCADE, + follow_up_date DATE, + flag_type VARCHAR(50), + completed BOOLEAN DEFAULT FALSE, + created_at TIMESTAMP DEFAULT NOW() +); + +-- Email nudges +CREATE TABLE IF NOT EXISTS email_nudges ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + email_id UUID NOT NULL REFERENCES emails(id) ON DELETE CASCADE, + last_sent TIMESTAMP, + dismissed BOOLEAN DEFAULT FALSE, + created_at TIMESTAMP DEFAULT NOW() +); + +-- Feature flags +CREATE TABLE IF NOT EXISTS feature_flags ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + org_id UUID NOT NULL, + feature VARCHAR(50) NOT NULL, + enabled BOOLEAN DEFAULT FALSE, + created_at TIMESTAMP DEFAULT NOW(), + UNIQUE(org_id, feature) +); + +-- Email-CRM links +CREATE TABLE IF NOT EXISTS email_crm_links ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + email_id UUID NOT NULL REFERENCES emails(id) ON DELETE CASCADE, + contact_id UUID, + opportunity_id UUID, + logged_at TIMESTAMP DEFAULT NOW() +); + +CREATE INDEX idx_email_crm_contact ON email_crm_links(contact_id); +CREATE INDEX idx_email_crm_opportunity ON email_crm_links(opportunity_id); + +-- Email-Campaign links +CREATE TABLE IF NOT EXISTS email_campaign_links ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + email_id UUID NOT NULL REFERENCES emails(id) ON DELETE CASCADE, + campaign_id UUID, + list_id UUID, + sent_at TIMESTAMP DEFAULT NOW() +); + +-- Offline queue +CREATE TABLE IF NOT EXISTS email_offline_queue ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL, + action VARCHAR(50) NOT NULL, + data JSONB NOT NULL, + created_at TIMESTAMP DEFAULT NOW() +); diff --git a/src/core/package_manager/facade.rs b/src/core/package_manager/facade.rs index 0037fa51..357561b1 100644 --- a/src/core/package_manager/facade.rs +++ b/src/core/package_manager/facade.rs @@ -11,22 +11,21 @@ use reqwest::Client; use std::collections::HashMap; use std::fmt::Write as FmtWrite; use std::path::PathBuf; -fn safe_lxc(args: &[&str]) -> Option { - let cmd_res = SafeCommand::new("lxc").and_then(|c| c.args(args)); - match cmd_res { - Ok(cmd) => match cmd.execute() { - Ok(output) => Some(output), - Err(e) => { - log::error!("Failed to execute lxc command '{:?}': {}", args, e); - None - } - }, - Err(e) => { - log::error!("Failed to build lxc command '{:?}': {}", args, e); - None - } - } +fn safe_lxc(args: &[&str]) -> Option { + SafeCommand::new("lxc") + .and_then(|c| c.args(args)) + .ok() + .and_then(|cmd| cmd.execute().ok()) +} + +fn safe_lxc_exec_in_container(container: &str, command: &str) -> Option { + let output = std::process::Command::new("lxc") + .args(["exec", container, "--", "bash", "-c", command]) + .output() + .ok()?; + + Some(output) } fn safe_lxd(args: &[&str]) -> Option { @@ -217,15 +216,6 @@ impl PackageManager { "mkdir -p /opt/gbo/bin /opt/gbo/data /opt/gbo/conf /opt/gbo/logs", )?; - self.exec_in_container( - &container_name, - "echo 'nameserver 8.8.8.8' > /etc/resolv.conf", - )?; - self.exec_in_container( - &container_name, - "echo 'nameserver 8.8.4.4' >> /etc/resolv.conf", - )?; - self.exec_in_container(&container_name, "apt-get update -qq")?; self.exec_in_container( &container_name, @@ -1114,7 +1104,7 @@ Store credentials in Vault: } pub fn exec_in_container(&self, container: &str, command: &str) -> Result<()> { info!("Executing in container {}: {}", container, command); - let output = safe_lxc(&["exec", container, "--", "bash", "-c", command]) + let output = safe_lxc_exec_in_container(container, command) .ok_or_else(|| anyhow::anyhow!("Failed to execute lxc command"))?; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); diff --git a/src/core/shared/schema/email_integration.rs b/src/core/shared/schema/email_integration.rs new file mode 100644 index 00000000..3b85b3ea --- /dev/null +++ b/src/core/shared/schema/email_integration.rs @@ -0,0 +1,61 @@ +// Email integration schema tables + +diesel::table! { + feature_flags (id) { + id -> Uuid, + org_id -> Uuid, + feature -> Varchar, + enabled -> Bool, + created_at -> Timestamp, + } +} + +diesel::table! { + email_crm_links (id) { + id -> Uuid, + email_id -> Uuid, + contact_id -> Nullable, + opportunity_id -> Nullable, + logged_at -> Timestamp, + } +} + +diesel::table! { + email_campaign_links (id) { + id -> Uuid, + email_id -> Uuid, + campaign_id -> Nullable, + list_id -> Nullable, + sent_at -> Timestamp, + } +} + +diesel::table! { + email_snooze (id) { + id -> Uuid, + email_id -> Uuid, + snooze_until -> Timestamp, + created_at -> Timestamp, + } +} + +diesel::table! { + email_flags (id) { + id -> Uuid, + email_id -> Uuid, + follow_up_date -> Nullable, + flag_type -> Nullable, + completed -> Bool, + created_at -> Timestamp, + } +} + +diesel::table! { + email_nudges (id) { + id -> Uuid, + email_id -> Uuid, + last_sent -> Nullable, + dismissed -> Bool, + created_at -> Timestamp, + } +} diff --git a/src/core/shared/schema/mod.rs b/src/core/shared/schema/mod.rs index 02e59c59..1c8924fc 100644 --- a/src/core/shared/schema/mod.rs +++ b/src/core/shared/schema/mod.rs @@ -84,3 +84,7 @@ pub mod project; #[cfg(feature = "dashboards")] pub mod dashboards; +// Email integration (always available) +pub mod email_integration; +pub use self::email_integration::*; + diff --git a/src/email/flags.rs b/src/email/flags.rs new file mode 100644 index 00000000..8e290ba9 --- /dev/null +++ b/src/email/flags.rs @@ -0,0 +1,93 @@ +use axum::{ + extract::State, + http::StatusCode, + response::Json, +}; +use chrono::{Datelike, Duration, NaiveDate, Utc}; +use diesel::prelude::*; +use serde::{Deserialize, Serialize}; +use std::sync::Arc; +use uuid::Uuid; + +use crate::core::shared::state::AppState; + +#[derive(Debug, Deserialize)] +pub struct FlagRequest { + pub email_ids: Vec, + pub follow_up: String, +} + +#[derive(Debug, Serialize)] +pub struct FlagResponse { + pub flagged_count: usize, +} + +/// Flag emails for follow-up +pub async fn flag_for_followup( + State(state): State>, + Json(req): Json, +) -> Result, StatusCode> { + use crate::core::shared::schema::email_flags; + + + let mut conn = state.conn.get().map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + let follow_up_date = calculate_followup_date(&req.follow_up); + + let mut flagged_count = 0; + for email_id in &req.email_ids { + diesel::insert_into(email_flags::table) + .values(( + email_flags::email_id.eq(email_id), + email_flags::follow_up_date.eq(follow_up_date), + email_flags::flag_type.eq(&req.follow_up), + )) + .execute(&mut conn) + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + flagged_count += 1; + } + + Ok(Json(FlagResponse { flagged_count })) +} + +/// Clear flag from email +pub async fn clear_flag( + State(state): State>, + Json(email_id): Json, +) -> Result { + use crate::core::shared::schema::email_flags; + + let mut conn = state.conn.get().map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + diesel::delete(email_flags::table.filter(email_flags::email_id.eq(email_id))) + .execute(&mut conn) + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + Ok(StatusCode::OK) +} + +fn calculate_followup_date(preset: &str) -> Option { + let now = Utc::now().date_naive(); + + match preset { + "today" => Some(now), + "tomorrow" => Some(now + Duration::days(1)), + "this-week" => Some(now + Duration::days(7 - now.weekday().num_days_from_monday() as i64)), + "next-week" => Some(now + Duration::days(7)), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_calculate_followup_date() { + let today = calculate_followup_date("today"); + assert!(today.is_some()); + + let tomorrow = calculate_followup_date("tomorrow"); + assert!(tomorrow.is_some()); + } +} diff --git a/src/email/integration.rs b/src/email/integration.rs new file mode 100644 index 00000000..88c0c857 --- /dev/null +++ b/src/email/integration.rs @@ -0,0 +1,192 @@ +use axum::{ + extract::{Path, State}, + http::StatusCode, + response::{IntoResponse, Json}, +}; +use diesel::prelude::*; +use serde_json::json; +use std::sync::Arc; +use uuid::Uuid; + +use crate::core::shared::state::AppState; +use super::integration_types::*; + +/// Check which features are enabled for the organization +pub async fn get_feature_flags( + State(state): State>, + Path(org_id): Path, +) -> Result, StatusCode> { + use crate::core::shared::schema::feature_flags; + + let mut conn = state.conn.get().map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + let flags: Vec<(String, bool)> = feature_flags::table + .filter(feature_flags::org_id.eq(org_id)) + .select((feature_flags::feature, feature_flags::enabled)) + .load(&mut conn) + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + let crm_enabled = flags.iter().any(|(f, e)| f == "crm" && *e); + let campaigns_enabled = flags.iter().any(|(f, e)| f == "campaigns" && *e); + + Ok(Json(FeatureFlags { + crm_enabled, + campaigns_enabled, + })) +} + +/// Extract lead information from email using AI +pub async fn extract_lead_from_email( + State(_state): State>, + Json(req): Json, +) -> Result, StatusCode> { + // Simple extraction logic (can be enhanced with LLM) + let email = req.from.clone(); + + // Extract name from email (before @) + let name_part = email.split('@').next().unwrap_or(""); + let parts: Vec<&str> = name_part.split('.').collect(); + + let first_name = parts.first().map(|s| capitalize(s)); + let last_name = if parts.len() > 1 { + parts.get(1).map(|s| capitalize(s)) + } else { + None + }; + + // Extract company from email domain + let company = email + .split('@') + .nth(1) + .and_then(|d| d.split('.').next()) + .map(|c| capitalize(c)); + + Ok(Json(LeadExtractionResponse { + first_name, + last_name, + email, + company, + phone: None, + value: None, + })) +} + +/// Get CRM context for an email sender +pub async fn get_crm_context_by_email( + State(state): State>, + Path(email): Path, +) -> Result { + use crate::core::shared::schema::crm_contacts; + + let mut conn = state.conn.get().map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + let contact = crm_contacts::table + .filter(crm_contacts::email.eq(&email)) + .first::(&mut conn) + .optional() + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + match contact { + Some(c) => Ok(Json(json!({ + "found": true, + "contact": c + }))), + None => Ok(Json(json!({ + "found": false + }))), + } +} + +/// Link email to CRM contact/opportunity +pub async fn link_email_to_crm( + State(state): State>, + Json(link): Json, +) -> Result { + use crate::core::shared::schema::email_crm_links; + + let mut conn = state.conn.get().map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + diesel::insert_into(email_crm_links::table) + .values(( + email_crm_links::email_id.eq(link.email_id), + email_crm_links::contact_id.eq(link.contact_id), + email_crm_links::opportunity_id.eq(link.opportunity_id), + )) + .execute(&mut conn) + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + Ok(StatusCode::CREATED) +} + +/// Categorize email using AI +pub async fn categorize_email( + State(_state): State>, + Json(req): Json, +) -> Result, StatusCode> { + // Simple keyword-based categorization (can be enhanced with LLM) + let text = format!("{} {}", req.subject.to_lowercase(), req.body.to_lowercase()); + + let category = if text.contains("quote") || text.contains("proposal") || text.contains("pricing") { + "sales" + } else if text.contains("support") || text.contains("help") || text.contains("issue") { + "support" + } else if text.contains("newsletter") || text.contains("unsubscribe") { + "marketing" + } else { + "general" + }; + + Ok(Json(EmailCategoryResponse { + category: category.to_string(), + confidence: 0.8, + })) +} + +/// Generate smart reply suggestions +pub async fn generate_smart_reply( + State(_state): State>, + Json(_req): Json, +) -> Result, StatusCode> { + // Simple template responses (can be enhanced with LLM) + let suggestions = vec![ + "Thank you for your email. I'll get back to you shortly.".to_string(), + "I appreciate you reaching out. Let me review this and respond soon.".to_string(), + "Thanks for the update. I'll take a look and follow up.".to_string(), + ]; + + Ok(Json(SmartReplyResponse { suggestions })) +} + +fn capitalize(s: &str) -> String { + let mut chars = s.chars(); + match chars.next() { + None => String::new(), + Some(first) => first.to_uppercase().collect::() + chars.as_str(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_capitalize() { + assert_eq!(capitalize("john"), "John"); + assert_eq!(capitalize(""), ""); + assert_eq!(capitalize("a"), "A"); + } + + #[test] + fn test_categorize_sales_email() { + let req = LeadExtractionRequest { + from: "test@example.com".to_string(), + subject: "Request for pricing quote".to_string(), + body: "I would like to get a quote for your services".to_string(), + }; + + // This would need async test setup + // For now, just test the logic + let text = format!("{} {}", req.subject.to_lowercase(), req.body.to_lowercase()); + assert!(text.contains("quote")); + } +} diff --git a/src/email/integration_types.rs b/src/email/integration_types.rs new file mode 100644 index 00000000..b6c4fb77 --- /dev/null +++ b/src/email/integration_types.rs @@ -0,0 +1,56 @@ +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FeatureFlags { + pub crm_enabled: bool, + pub campaigns_enabled: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct EmailCrmLink { + pub email_id: Uuid, + pub contact_id: Option, + pub opportunity_id: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct EmailCampaignLink { + pub email_id: Uuid, + pub campaign_id: Option, + pub list_id: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LeadExtractionRequest { + pub from: String, + pub subject: String, + pub body: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LeadExtractionResponse { + pub first_name: Option, + pub last_name: Option, + pub email: String, + pub company: Option, + pub phone: Option, + pub value: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SmartReplyRequest { + pub email_id: Uuid, + pub context: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SmartReplyResponse { + pub suggestions: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct EmailCategoryResponse { + pub category: String, + pub confidence: f32, +} diff --git a/src/email/mod.rs b/src/email/mod.rs index bca2f1db..199206db 100644 --- a/src/email/mod.rs +++ b/src/email/mod.rs @@ -16,6 +16,17 @@ pub mod messages; pub mod tracking; pub mod signatures; pub mod htmx; +pub mod integration_types; +pub mod integration; +pub mod snooze; +pub mod nudges; +pub mod flags; + +#[cfg(test)] +mod integration_types_test; + +#[cfg(test)] +mod integration_tests; pub use types::*; pub use accounts::*; @@ -23,6 +34,39 @@ pub use messages::*; pub use tracking::*; pub use signatures::*; pub use htmx::*; +pub use integration_types::*; +pub use integration::*; +pub use snooze::*; +pub use nudges::*; +pub use flags::*; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_feature_flags() { + let flags = FeatureFlags { + crm_enabled: true, + campaigns_enabled: false, + }; + assert!(flags.crm_enabled); + assert!(!flags.campaigns_enabled); + } + + #[test] + fn test_lead_extraction() { + let response = LeadExtractionResponse { + first_name: Some("John".to_string()), + last_name: Some("Doe".to_string()), + email: "john@example.com".to_string(), + company: Some("Acme".to_string()), + phone: None, + value: Some(50000.0), + }; + assert_eq!(response.email, "john@example.com"); + } +} pub fn configure() -> Router> { Router::new() @@ -74,6 +118,20 @@ pub fn configure() -> Router> { .route(ApiUrls::EMAIL_SIGNATURES_HTMX, get(list_signatures_htmx)) .route(ApiUrls::EMAIL_RULES_HTMX, get(list_rules_htmx)) .route(ApiUrls::EMAIL_SEARCH_HTMX, get(search_emails_htmx)) + // Integration routes + .route("/api/features/:org_id/enabled", get(get_feature_flags)) + .route("/api/ai/extract-lead", post(extract_lead_from_email)) + .route("/api/crm/contact/by-email/:email", get(get_crm_context_by_email)) + .route("/api/email/crm/link", post(link_email_to_crm)) + .route("/api/ai/categorize-email", post(categorize_email)) + .route("/api/ai/generate-reply", post(generate_smart_reply)) + // Email features + .route("/api/email/snooze", post(snooze_emails)) + .route("/api/email/snoozed", get(get_snoozed_emails)) + .route("/api/email/nudges", post(check_nudges)) + .route("/api/email/nudge/dismiss", post(dismiss_nudge)) + .route("/api/email/flag", post(flag_for_followup)) + .route("/api/email/flag/clear", post(clear_flag)) .route(ApiUrls::EMAIL_AUTO_RESPONDER_HTMX, post(save_auto_responder)) .route("/api/email/signatures", get(list_signatures).post(create_signature)) .route("/api/email/signatures/default", get(get_default_signature)) diff --git a/src/email/nudges.rs b/src/email/nudges.rs new file mode 100644 index 00000000..f8de2a87 --- /dev/null +++ b/src/email/nudges.rs @@ -0,0 +1,57 @@ +use axum::{ + extract::State, + http::StatusCode, + response::Json, +}; +use diesel::prelude::*; +use serde::{Deserialize, Serialize}; +use std::sync::Arc; +use uuid::Uuid; + +use crate::core::shared::state::AppState; + +#[derive(Debug, Deserialize)] +pub struct NudgeCheckRequest { + pub user_id: Uuid, +} + +#[derive(Debug, Serialize)] +pub struct Nudge { + pub email_id: Uuid, + pub from: String, + pub subject: String, + pub days_ago: i64, +} + +#[derive(Debug, Serialize)] +pub struct NudgesResponse { + pub nudges: Vec, +} + +/// Check for emails that need follow-up nudges +pub async fn check_nudges( + State(_state): State>, + Json(_req): Json, +) -> Result, StatusCode> { + // Simple implementation - can be enhanced with actual email tracking + let nudges = vec![]; + + Ok(Json(NudgesResponse { nudges })) +} + +/// Dismiss a nudge +pub async fn dismiss_nudge( + State(state): State>, + Json(email_id): Json, +) -> Result { + use crate::core::shared::schema::email_nudges; + + let mut conn = state.conn.get().map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + diesel::update(email_nudges::table.filter(email_nudges::email_id.eq(email_id))) + .set(email_nudges::dismissed.eq(true)) + .execute(&mut conn) + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + Ok(StatusCode::OK) +} diff --git a/src/email/snooze.rs b/src/email/snooze.rs new file mode 100644 index 00000000..28d93341 --- /dev/null +++ b/src/email/snooze.rs @@ -0,0 +1,137 @@ +use axum::{ + extract::State, + http::StatusCode, + response::Json, +}; +use chrono::{DateTime, Datelike, Duration, Utc}; +use diesel::prelude::*; +use serde::{Deserialize, Serialize}; +use std::sync::Arc; +use uuid::Uuid; + +use crate::core::shared::state::AppState; + +#[derive(Debug, Deserialize)] +pub struct SnoozeRequest { + pub email_ids: Vec, + pub preset: String, +} + +#[derive(Debug, Serialize)] +pub struct SnoozeResponse { + pub snoozed_count: usize, + pub snooze_until: DateTime, +} + +/// Snooze emails until a specific time +pub async fn snooze_emails( + State(state): State>, + Json(req): Json, +) -> Result, StatusCode> { + use crate::core::shared::schema::email_snooze; + + let mut conn = state.conn.get().map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + let snooze_until = calculate_snooze_time(&req.preset); + let snooze_until_naive = snooze_until.naive_utc(); + + let mut snoozed_count = 0; + for email_id in &req.email_ids { + diesel::insert_into(email_snooze::table) + .values(( + email_snooze::email_id.eq(email_id), + email_snooze::snooze_until.eq(snooze_until_naive), + )) + .execute(&mut conn) + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + snoozed_count += 1; + } + + Ok(Json(SnoozeResponse { + snoozed_count, + snooze_until, + })) +} + +/// Get snoozed emails that are ready to be shown +pub async fn get_snoozed_emails( + State(state): State>, +) -> Result>, StatusCode> { + use crate::core::shared::schema::email_snooze; + + let mut conn = state.conn.get().map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + let now = Utc::now().naive_utc(); + + let email_ids: Vec = email_snooze::table + .filter(email_snooze::snooze_until.le(now)) + .select(email_snooze::email_id) + .load(&mut conn) + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + // Delete processed snoozes + diesel::delete(email_snooze::table.filter(email_snooze::snooze_until.le(now))) + .execute(&mut conn) + .ok(); + + Ok(Json(email_ids)) +} + +fn calculate_snooze_time(preset: &str) -> DateTime { + let now = Utc::now(); + + match preset { + "later-today" => { + // 6 PM today + let today = now.date_naive(); + today + .and_hms_opt(18, 0, 0) + .map(|dt| DateTime::from_naive_utc_and_offset(dt, Utc)) + .unwrap_or(now + Duration::hours(6)) + } + "tomorrow" => { + // 8 AM tomorrow + let tomorrow = (now + Duration::days(1)).date_naive(); + tomorrow + .and_hms_opt(8, 0, 0) + .map(|dt| DateTime::from_naive_utc_and_offset(dt, Utc)) + .unwrap_or(now + Duration::days(1)) + } + "this-weekend" => { + // Saturday 9 AM + let days_until_saturday = (6 - now.weekday().num_days_from_monday()) % 7; + let saturday = (now + Duration::days(days_until_saturday as i64)).date_naive(); + saturday + .and_hms_opt(9, 0, 0) + .map(|dt| DateTime::from_naive_utc_and_offset(dt, Utc)) + .unwrap_or(now + Duration::days(days_until_saturday as i64)) + } + "next-week" => { + // Monday 8 AM next week + let days_until_next_monday = (7 - now.weekday().num_days_from_monday() + 1) % 7 + 7; + let next_monday = (now + Duration::days(days_until_next_monday as i64)).date_naive(); + next_monday + .and_hms_opt(8, 0, 0) + .map(|dt| DateTime::from_naive_utc_and_offset(dt, Utc)) + .unwrap_or(now + Duration::days(days_until_next_monday as i64)) + } + _ => now + Duration::hours(1), // Default: 1 hour + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_calculate_snooze_time() { + let later_today = calculate_snooze_time("later-today"); + assert!(later_today > Utc::now()); + + let tomorrow = calculate_snooze_time("tomorrow"); + assert!(tomorrow > Utc::now()); + + let weekend = calculate_snooze_time("this-weekend"); + assert!(weekend > Utc::now()); + } +} diff --git a/src/security/command_guard.rs b/src/security/command_guard.rs index 53bf3861..b19a3566 100644 --- a/src/security/command_guard.rs +++ b/src/security/command_guard.rs @@ -297,9 +297,12 @@ impl SafeCommand { // Build PATH with standard locations plus botserver-stack/bin/shared let mut path_entries = vec![ + "/snap/bin".to_string(), "/usr/local/bin".to_string(), "/usr/bin".to_string(), "/bin".to_string(), + "/usr/sbin".to_string(), + "/sbin".to_string(), ]; // Add botserver-stack/bin/shared to PATH if it exists