Skip to main content

offline_intelligence/api/
feedback_api.rs

1// Feedback API: receives user feedback, saves locally, and optionally emails admin
2use axum::{
3    extract::Json,
4    http::StatusCode,
5};
6use serde::{Deserialize, Serialize};
7use tracing::{info, error, warn};
8
9#[derive(Debug, Deserialize)]
10pub struct FeedbackRequest {
11    pub message: String,
12    #[serde(default)]
13    pub email: String,
14}
15
16#[derive(Debug, Serialize)]
17pub struct FeedbackResponse {
18    pub success: bool,
19    pub message: String,
20}
21
22pub async fn submit_feedback(
23    Json(payload): Json<FeedbackRequest>,
24) -> (StatusCode, Json<FeedbackResponse>) {
25    if payload.message.trim().is_empty() {
26        return (
27            StatusCode::BAD_REQUEST,
28            Json(FeedbackResponse {
29                success: false,
30                message: "Feedback message cannot be empty".to_string(),
31            }),
32        );
33    }
34
35    info!("Received feedback submission ({} chars)", payload.message.len());
36
37    // Always save feedback locally first
38    if let Err(e) = save_feedback_locally(&payload) {
39        error!("Failed to save feedback locally: {}", e);
40        return (
41            StatusCode::INTERNAL_SERVER_ERROR,
42            Json(FeedbackResponse {
43                success: false,
44                message: "Failed to save feedback. Please try again.".to_string(),
45            }),
46        );
47    }
48
49    info!("Feedback saved locally");
50
51    // Try to send email if SMTP is configured (non-blocking — don't fail if email fails)
52    match send_feedback_email(&payload).await {
53        Ok(feedback_number) => info!("Feedback email #{} sent to product team", feedback_number),
54        Err(e) => warn!("Email not sent (SMTP may not be configured): {}", e),
55    }
56
57    (
58        StatusCode::OK,
59        Json(FeedbackResponse {
60            success: true,
61            message: "Feedback submitted successfully. Thank you!".to_string(),
62        }),
63    )
64}
65
66/// Returns the canonical Aud.io data directory used by all backend components.
67/// - Windows : `%APPDATA%\Aud.io\data`
68/// - macOS   : `~/Library/Application Support/Aud.io/data`
69/// - Linux   : `~/.local/share/Aud.io/data`
70fn aud_io_data_dir() -> std::path::PathBuf {
71    dirs::data_dir()
72        .unwrap_or_else(|| std::env::current_dir().unwrap_or_default())
73        .join("Aud.io")
74        .join("data")
75}
76
77fn save_feedback_locally(payload: &FeedbackRequest) -> Result<(), Box<dyn std::error::Error>> {
78    let db_dir = aud_io_data_dir();
79    std::fs::create_dir_all(&db_dir)?;
80
81    let conn = rusqlite::Connection::open(db_dir.join("feedback.db"))?;
82
83    conn.execute_batch(
84        "CREATE TABLE IF NOT EXISTS feedback (
85            id INTEGER PRIMARY KEY AUTOINCREMENT,
86            message TEXT NOT NULL,
87            email TEXT DEFAULT '',
88            created_at DATETIME DEFAULT CURRENT_TIMESTAMP
89        );"
90    )?;
91
92    conn.execute(
93        "INSERT INTO feedback (message, email) VALUES (?1, ?2)",
94        rusqlite::params![payload.message, payload.email],
95    )?;
96
97    Ok(())
98}
99
100/// Get the next feedback number for sequential tracking
101fn get_next_feedback_number() -> u64 {
102    let counter_path = aud_io_data_dir().join("feedback_counter.txt");
103    
104    // Read current counter
105    let current = if counter_path.exists() {
106        std::fs::read_to_string(&counter_path)
107            .ok()
108            .and_then(|s| s.trim().parse().ok())
109            .unwrap_or(0)
110    } else {
111        0
112    };
113    
114    let next = current + 1;
115    
116    // Save next counter
117    if let Some(parent) = counter_path.parent() {
118        let _ = std::fs::create_dir_all(parent);
119    }
120    let _ = std::fs::write(counter_path, next.to_string());
121    
122    next
123}
124
125async fn send_feedback_email(payload: &FeedbackRequest) -> Result<u64, Box<dyn std::error::Error>> {
126    use lettre::{
127        Message, SmtpTransport, Transport,
128        message::header::ContentType,
129        transport::smtp::authentication::Credentials,
130    };
131
132    // Runtime env vars take priority; fall back to values baked in at compile time
133    // by build.rs so that installed/distributed builds (no .env file) still work.
134    let smtp_user = std::env::var("SMTP_USER")
135        .unwrap_or_else(|_| option_env!("SMTP_USER").unwrap_or("").to_string());
136    let smtp_pass = std::env::var("SMTP_PASS")
137        .unwrap_or_else(|_| option_env!("SMTP_PASS").unwrap_or("").to_string());
138
139    if smtp_user.is_empty() || smtp_pass.is_empty() {
140        return Err("SMTP credentials not configured".into());
141    }
142
143    let smtp_host = std::env::var("SMTP_HOST")
144        .unwrap_or_else(|_| option_env!("SMTP_HOST").unwrap_or("smtp.hostinger.com").to_string());
145    let smtp_port: u16 = std::env::var("SMTP_PORT")
146        .unwrap_or_else(|_| option_env!("SMTP_PORT").unwrap_or("587").to_string())
147        .parse()
148        .unwrap_or(587);
149
150    // Get sequential feedback number
151    let feedback_number = get_next_feedback_number();
152
153    // Always send FROM the authenticated SMTP user to avoid sender rejection
154    // Use Reply-To for the user's email so admin can respond directly
155    let from_address = format!("Aud.io Feedback <{}>", smtp_user);
156    
157    let mut email_builder = Message::builder()
158        .from(from_address.parse()?)
159        .to("Product Team <product@offlineintelligence.io>".parse()?)
160        .subject(format!("FEEDBACK #{} - _Aud.io User Feedback", feedback_number));
161    
162    // Add Reply-To header if user provided email
163    if !payload.email.is_empty() {
164        email_builder = email_builder.reply_to(payload.email.parse()?);
165    }
166    
167    let email = email_builder
168        .header(ContentType::TEXT_PLAIN)
169        .body(format!(
170            "FEEDBACK #{}\n\nNew feedback from _Aud.io user:\n\n{}\n\n---\nUser email: {}",
171            feedback_number,
172            payload.message,
173            if payload.email.is_empty() { "Not provided" } else { &payload.email }
174        ))?;
175
176    let creds = Credentials::new(smtp_user, smtp_pass);
177
178    let mailer = SmtpTransport::starttls_relay(&smtp_host)?
179        .port(smtp_port)
180        .credentials(creds)
181        .build();
182
183    mailer.send(&email)?;
184
185    Ok(feedback_number)
186}