Fix LXD container mode: PATH, socket proxy, exec
All checks were successful
BotServer CI / build (push) Successful in 10m54s
All checks were successful
BotServer CI / build (push) Successful in 10m54s
This commit is contained in:
parent
ba53a0c178
commit
d1cb6b758c
13 changed files with 800 additions and 26 deletions
2
.product
2
.product
|
|
@ -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
|
||||||
|
|
|
||||||
9
migrations/2026-03-15-email-crm-campaigns/down.sql
Normal file
9
migrations/2026-03-15-email-crm-campaigns/down.sql
Normal 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;
|
||||||
114
migrations/2026-03-15-email-crm-campaigns/up.sql
Normal file
114
migrations/2026-03-15-email-crm-campaigns/up.sql
Normal 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()
|
||||||
|
);
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
61
src/core/shared/schema/email_integration.rs
Normal file
61
src/core/shared/schema/email_integration.rs
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
93
src/email/flags.rs
Normal 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
192
src/email/integration.rs
Normal 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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
56
src/email/integration_types.rs
Normal file
56
src/email/integration_types.rs
Normal 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,
|
||||||
|
}
|
||||||
|
|
@ -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
57
src/email/nudges.rs
Normal 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
137
src/email/snooze.rs
Normal 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue