Initial commit: telfax - Rust Fax Server

- Modem driver (serialport) with AT command interface
- Class 1 fax protocol (T.30 session, HDLC framing)
- T.4/T.6 image codec (fax crate integration)
- Document conversion (image to fax, TIFF-F output)
- REST API (axum) for fax job management
- SQLite/InMemory job queue
- CLI interface (detect/serve/send/test)
- Integration tests passing
This commit is contained in:
Warren
2026-07-01 09:06:41 +08:00
commit 13438289fe
28 changed files with 4615 additions and 0 deletions
+128
View File
@@ -0,0 +1,128 @@
use axum::{
extract::State,
http::StatusCode,
response::Json,
routing::{get, post},
Router,
};
use serde::{Deserialize, Serialize};
use std::sync::{Arc, Mutex};
use tracing::info;
use crate::config::FaxConfig;
use crate::error::Result as FaxResult;
use crate::queue::job::{FaxJob, JobId};
use crate::queue::store::FaxQueue;
#[derive(Clone)]
pub struct AppState {
pub config: FaxConfig,
pub queue: Arc<Mutex<FaxQueue>>,
}
#[derive(Serialize)]
pub struct HealthResponse {
pub status: String,
pub device: String,
}
#[derive(Deserialize)]
pub struct SendFaxRequest {
pub recipient: String,
pub document_path: String,
}
#[derive(Serialize)]
pub struct SendFaxResponse {
pub job_id: JobId,
}
#[derive(Serialize)]
pub struct JobResponse {
pub id: JobId,
pub recipient: String,
pub status: String,
pub pages: u32,
pub created_at: String,
}
async fn health(State(state): State<AppState>) -> Json<HealthResponse> {
Json(HealthResponse {
status: "ok".to_string(),
device: state.config.device.clone(),
})
}
async fn send_fax(
State(state): State<AppState>,
Json(req): Json<SendFaxRequest>,
) -> Result<Json<SendFaxResponse>, StatusCode> {
let job = FaxJob::new(req.recipient, req.document_path);
let job_id = job.id;
let mut queue = state.queue.lock().unwrap();
queue.enqueue(job).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
info!(job_id = %job_id, "Fax job queued");
Ok(Json(SendFaxResponse { job_id }))
}
async fn list_jobs(State(state): State<AppState>) -> Json<Vec<JobResponse>> {
let queue = state.queue.lock().unwrap();
let jobs = queue.list();
let responses: Vec<JobResponse> = jobs
.into_iter()
.map(|j| JobResponse {
id: j.id,
recipient: j.recipient,
status: format!("{:?}", j.status),
pages: j.pages,
created_at: j.created_at.to_rfc3339(),
})
.collect();
Json(responses)
}
async fn get_job(
State(state): State<AppState>,
axum::extract::Path(id): axum::extract::Path<JobId>,
) -> Result<Json<JobResponse>, StatusCode> {
let queue = state.queue.lock().unwrap();
queue.get(&id).map(|j| Json(JobResponse {
id: j.id,
recipient: j.recipient,
status: format!("{:?}", j.status),
pages: j.pages,
created_at: j.created_at.to_rfc3339(),
})).ok_or(StatusCode::NOT_FOUND)
}
pub async fn start_server(config: FaxConfig, queue: Arc<Mutex<FaxQueue>>) -> FaxResult<()> {
let state = AppState {
config: config.clone(),
queue,
};
let app = Router::new()
.route("/api/health", get(health))
.route("/api/fax/send", post(send_fax))
.route("/api/fax/jobs", get(list_jobs))
.route("/api/fax/jobs/{id}", get(get_job))
.with_state(state);
let addr = config
.api_listen
.clone()
.unwrap_or_else(|| "0.0.0.0:3000".to_string());
info!("Starting API server on {}", addr);
let listener = tokio::net::TcpListener::bind(&addr)
.await
.map_err(|e| crate::error::FaxError::Other(format!("Failed to bind: {}", e)))?;
axum::serve(listener, app)
.await
.map_err(|e| crate::error::FaxError::Other(format!("Server error: {}", e)))?;
Ok(())
}