feat: add project and email service
This commit is contained in:
parent
99715f6105
commit
f48390b15e
29 changed files with 2631 additions and 63 deletions
184
api/src/handlers.rs
Normal file
184
api/src/handlers.rs
Normal 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, ¬ify_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" }))
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue