Fix LXD container mode: PATH, socket proxy, exec
All checks were successful
BotServer CI / build (push) Successful in 10m54s

This commit is contained in:
Rodrigo Rodriguez (Pragmatismo) 2026-03-15 20:00:06 -03:00
parent ba53a0c178
commit d1cb6b758c
13 changed files with 800 additions and 26 deletions

View file

@ -12,7 +12,7 @@ name=General Bots
# Available apps: chat, mail, calendar, drive, tasks, docs, paper, sheet, slides, # Available apps: chat, mail, calendar, drive, tasks, docs, paper, sheet, slides,
# meet, research, sources, analytics, admin, monitoring, settings # meet, research, sources, analytics, admin, monitoring, settings
# Only listed apps will be visible in the UI and have their APIs enabled. # 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 # Search mechanism enabled
# Controls whether the omnibox/search toolbar is displayed in the suite # Controls whether the omnibox/search toolbar is displayed in the suite

View file

@ -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;

View file

@ -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()
);

View file

@ -11,22 +11,21 @@ use reqwest::Client;
use std::collections::HashMap; use std::collections::HashMap;
use std::fmt::Write as FmtWrite; use std::fmt::Write as FmtWrite;
use std::path::PathBuf; use std::path::PathBuf;
fn safe_lxc(args: &[&str]) -> Option<std::process::Output> {
let cmd_res = SafeCommand::new("lxc").and_then(|c| c.args(args));
match cmd_res { fn safe_lxc(args: &[&str]) -> Option<std::process::Output> {
Ok(cmd) => match cmd.execute() { SafeCommand::new("lxc")
Ok(output) => Some(output), .and_then(|c| c.args(args))
Err(e) => { .ok()
log::error!("Failed to execute lxc command '{:?}': {}", args, e); .and_then(|cmd| cmd.execute().ok())
None }
}
}, fn safe_lxc_exec_in_container(container: &str, command: &str) -> Option<std::process::Output> {
Err(e) => { let output = std::process::Command::new("lxc")
log::error!("Failed to build lxc command '{:?}': {}", args, e); .args(["exec", container, "--", "bash", "-c", command])
None .output()
} .ok()?;
}
Some(output)
} }
fn safe_lxd(args: &[&str]) -> Option<std::process::Output> { fn safe_lxd(args: &[&str]) -> Option<std::process::Output> {
@ -217,15 +216,6 @@ impl PackageManager {
"mkdir -p /opt/gbo/bin /opt/gbo/data /opt/gbo/conf /opt/gbo/logs", "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, "apt-get update -qq")?;
self.exec_in_container( self.exec_in_container(
&container_name, &container_name,
@ -1114,7 +1104,7 @@ Store credentials in Vault:
} }
pub fn exec_in_container(&self, container: &str, command: &str) -> Result<()> { pub fn exec_in_container(&self, container: &str, command: &str) -> Result<()> {
info!("Executing in container {}: {}", container, command); 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"))?; .ok_or_else(|| anyhow::anyhow!("Failed to execute lxc command"))?;
if !output.status.success() { if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr); let stderr = String::from_utf8_lossy(&output.stderr);

View file

@ -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<Uuid>,
opportunity_id -> Nullable<Uuid>,
logged_at -> Timestamp,
}
}
diesel::table! {
email_campaign_links (id) {
id -> Uuid,
email_id -> Uuid,
campaign_id -> Nullable<Uuid>,
list_id -> Nullable<Uuid>,
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<Date>,
flag_type -> Nullable<Varchar>,
completed -> Bool,
created_at -> Timestamp,
}
}
diesel::table! {
email_nudges (id) {
id -> Uuid,
email_id -> Uuid,
last_sent -> Nullable<Timestamp>,
dismissed -> Bool,
created_at -> Timestamp,
}
}

View file

@ -84,3 +84,7 @@ pub mod project;
#[cfg(feature = "dashboards")] #[cfg(feature = "dashboards")]
pub mod dashboards; pub mod dashboards;
// Email integration (always available)
pub mod email_integration;
pub use self::email_integration::*;

93
src/email/flags.rs Normal file
View file

@ -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<Uuid>,
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<Arc<AppState>>,
Json(req): Json<FlagRequest>,
) -> Result<Json<FlagResponse>, 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<Arc<AppState>>,
Json(email_id): Json<Uuid>,
) -> Result<StatusCode, StatusCode> {
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<NaiveDate> {
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());
}
}

192
src/email/integration.rs Normal file
View file

@ -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<Arc<AppState>>,
Path(org_id): Path<Uuid>,
) -> Result<Json<FeatureFlags>, 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<Arc<AppState>>,
Json(req): Json<LeadExtractionRequest>,
) -> Result<Json<LeadExtractionResponse>, 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<Arc<AppState>>,
Path(email): Path<String>,
) -> Result<impl IntoResponse, StatusCode> {
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::<crate::contacts::crm::CrmContact>(&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<Arc<AppState>>,
Json(link): Json<EmailCrmLink>,
) -> Result<StatusCode, StatusCode> {
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<Arc<AppState>>,
Json(req): Json<LeadExtractionRequest>,
) -> Result<Json<EmailCategoryResponse>, 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<Arc<AppState>>,
Json(_req): Json<SmartReplyRequest>,
) -> Result<Json<SmartReplyResponse>, 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::<String>() + 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"));
}
}

View file

@ -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<Uuid>,
pub opportunity_id: Option<Uuid>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EmailCampaignLink {
pub email_id: Uuid,
pub campaign_id: Option<Uuid>,
pub list_id: Option<Uuid>,
}
#[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<String>,
pub last_name: Option<String>,
pub email: String,
pub company: Option<String>,
pub phone: Option<String>,
pub value: Option<f64>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SmartReplyRequest {
pub email_id: Uuid,
pub context: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SmartReplyResponse {
pub suggestions: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EmailCategoryResponse {
pub category: String,
pub confidence: f32,
}

View file

@ -16,6 +16,17 @@ pub mod messages;
pub mod tracking; pub mod tracking;
pub mod signatures; pub mod signatures;
pub mod htmx; 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 types::*;
pub use accounts::*; pub use accounts::*;
@ -23,6 +34,39 @@ pub use messages::*;
pub use tracking::*; pub use tracking::*;
pub use signatures::*; pub use signatures::*;
pub use htmx::*; 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<Arc<AppState>> { pub fn configure() -> Router<Arc<AppState>> {
Router::new() Router::new()
@ -74,6 +118,20 @@ pub fn configure() -> Router<Arc<AppState>> {
.route(ApiUrls::EMAIL_SIGNATURES_HTMX, get(list_signatures_htmx)) .route(ApiUrls::EMAIL_SIGNATURES_HTMX, get(list_signatures_htmx))
.route(ApiUrls::EMAIL_RULES_HTMX, get(list_rules_htmx)) .route(ApiUrls::EMAIL_RULES_HTMX, get(list_rules_htmx))
.route(ApiUrls::EMAIL_SEARCH_HTMX, get(search_emails_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(ApiUrls::EMAIL_AUTO_RESPONDER_HTMX, post(save_auto_responder))
.route("/api/email/signatures", get(list_signatures).post(create_signature)) .route("/api/email/signatures", get(list_signatures).post(create_signature))
.route("/api/email/signatures/default", get(get_default_signature)) .route("/api/email/signatures/default", get(get_default_signature))

57
src/email/nudges.rs Normal file
View file

@ -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<Nudge>,
}
/// Check for emails that need follow-up nudges
pub async fn check_nudges(
State(_state): State<Arc<AppState>>,
Json(_req): Json<NudgeCheckRequest>,
) -> Result<Json<NudgesResponse>, 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<Arc<AppState>>,
Json(email_id): Json<Uuid>,
) -> Result<StatusCode, StatusCode> {
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)
}

137
src/email/snooze.rs Normal file
View file

@ -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<Uuid>,
pub preset: String,
}
#[derive(Debug, Serialize)]
pub struct SnoozeResponse {
pub snoozed_count: usize,
pub snooze_until: DateTime<Utc>,
}
/// Snooze emails until a specific time
pub async fn snooze_emails(
State(state): State<Arc<AppState>>,
Json(req): Json<SnoozeRequest>,
) -> Result<Json<SnoozeResponse>, 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<Arc<AppState>>,
) -> Result<Json<Vec<Uuid>>, 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<Uuid> = 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<Utc> {
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());
}
}

View file

@ -297,9 +297,12 @@ impl SafeCommand {
// Build PATH with standard locations plus botserver-stack/bin/shared // Build PATH with standard locations plus botserver-stack/bin/shared
let mut path_entries = vec![ let mut path_entries = vec![
"/snap/bin".to_string(),
"/usr/local/bin".to_string(), "/usr/local/bin".to_string(),
"/usr/bin".to_string(), "/usr/bin".to_string(),
"/bin".to_string(), "/bin".to_string(),
"/usr/sbin".to_string(),
"/sbin".to_string(),
]; ];
// Add botserver-stack/bin/shared to PATH if it exists // Add botserver-stack/bin/shared to PATH if it exists