feat: add project and email service

This commit is contained in:
Jet Pham 2026-03-11 13:00:51 -07:00 committed by Jet
parent 99715f6105
commit f48390b15e
29 changed files with 2631 additions and 63 deletions

184
api/src/handlers.rs Normal file
View file

@ -0,0 +1,184 @@
use std::sync::Arc;
use axum::extract::State;
use axum::http::{HeaderMap, StatusCode};
use axum::Json;
use serde::{Deserialize, Serialize};
use crate::email;
use crate::serve::AppState;
#[derive(Serialize)]
pub struct Question {
id: i64,
question: String,
answer: String,
created_at: String,
answered_at: String,
}
pub async fn get_questions(
State(state): State<Arc<AppState>>,
) -> Result<Json<Vec<Question>>, StatusCode> {
let db = state.db.lock().map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let mut stmt = db
.prepare(
"SELECT id, question, answer, created_at, answered_at \
FROM questions WHERE answer IS NOT NULL \
ORDER BY answered_at DESC",
)
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let questions = stmt
.query_map([], |row| {
Ok(Question {
id: row.get(0)?,
question: row.get(1)?,
answer: row.get(2)?,
created_at: row.get(3)?,
answered_at: row.get(4)?,
})
})
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
.collect::<Result<Vec<_>, _>>()
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
Ok(Json(questions))
}
#[derive(Deserialize)]
pub struct SubmitQuestion {
question: String,
}
pub async fn post_question(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
Json(body): Json<SubmitQuestion>,
) -> Result<StatusCode, (StatusCode, String)> {
if body.question.is_empty() || body.question.len() > 200 {
return Err((
StatusCode::BAD_REQUEST,
"Question must be 1-200 characters".to_string(),
));
}
let ip = headers
.get("x-forwarded-for")
.and_then(|v| v.to_str().ok())
.and_then(|s| s.split(',').next())
.map(|s| s.trim().to_string())
.unwrap_or_else(|| "unknown".to_string());
if !state.rate_limiter.check(&ip) {
return Err((
StatusCode::TOO_MANY_REQUESTS,
"Too many questions. Try again later.".to_string(),
));
}
let id: i64 = {
let db = state
.db
.lock()
.map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "db error".to_string()))?;
db.execute(
"INSERT INTO questions (question) VALUES (?1)",
rusqlite::params![body.question],
)
.map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "insert error".to_string()))?;
db.last_insert_rowid()
};
let notify_email = state.notify_email.clone();
let question_text = body.question.clone();
tokio::task::spawn_blocking(move || {
if let Err(e) = email::send_notification(id, &question_text, &notify_email) {
eprintln!("Failed to send notification: {e}");
}
});
Ok(StatusCode::CREATED)
}
// --- MTA Hook webhook types ---
#[derive(Deserialize)]
pub struct MtaHookPayload {
#[serde(default)]
pub messages: Vec<MtaHookMessage>,
}
#[derive(Deserialize)]
pub struct MtaHookMessage {
#[serde(default)]
pub envelope: Envelope,
#[serde(default)]
pub contents: String,
}
#[derive(Deserialize, Default)]
pub struct Envelope {
#[serde(default)]
pub to: Vec<String>,
}
#[derive(Serialize)]
pub struct MtaHookResponse {
pub action: &'static str,
}
pub async fn webhook(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
Json(payload): Json<MtaHookPayload>,
) -> Result<Json<MtaHookResponse>, (StatusCode, String)> {
// Verify webhook secret
let secret = headers
.get("X-Webhook-Secret")
.and_then(|v| v.to_str().ok())
.unwrap_or("");
if secret != state.webhook_secret {
return Err((StatusCode::UNAUTHORIZED, "invalid secret".to_string()));
}
for message in &payload.messages {
// Find a qa+<id> recipient
let qa_recipient = message.envelope.to.iter().find(|addr| {
let local = addr.split('@').next().unwrap_or("");
local.starts_with("qa+")
});
let recipient = match qa_recipient {
Some(r) => r,
None => continue, // not a Q&A reply, skip
};
let id = match email::extract_id_from_address(recipient) {
Ok(id) => id,
Err(_) => continue,
};
let body = email::strip_quoted_text(&message.contents);
if body.is_empty() {
continue;
}
let db = state
.db
.lock()
.map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "db error".to_string()))?;
db.execute(
"UPDATE questions SET answer = ?1, answered_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') \
WHERE id = ?2 AND answer IS NULL",
rusqlite::params![body, id],
)
.map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "update error".to_string()))?;
return Ok(Json(MtaHookResponse { action: "discard" }));
}
// No Q&A recipient matched — let Stalwart deliver normally
Ok(Json(MtaHookResponse { action: "accept" }))
}