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
+43
View File
@@ -0,0 +1,43 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
pub type JobId = Uuid;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum JobStatus {
Queued,
Dialing,
Sending,
Completed,
Failed(String),
Cancelled,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FaxJob {
pub id: JobId,
pub recipient: String,
pub document_path: String,
pub status: JobStatus,
pub pages: u32,
pub retries: u8,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
impl FaxJob {
pub fn new(recipient: String, document_path: String) -> Self {
let now = Utc::now();
Self {
id: Uuid::new_v4(),
recipient,
document_path,
status: JobStatus::Queued,
pages: 0,
retries: 0,
created_at: now,
updated_at: now,
}
}
}
+5
View File
@@ -0,0 +1,5 @@
pub mod job;
pub mod store;
pub use job::{FaxJob, JobId, JobStatus};
pub use store::FaxQueue;
+121
View File
@@ -0,0 +1,121 @@
use crate::error::Result;
use crate::queue::job::{FaxJob, JobId, JobStatus};
use std::collections::HashMap;
use std::path::Path;
#[cfg(feature = "server")]
use rusqlite::Connection;
pub enum FaxQueue {
InMemory(InMemoryStore),
#[cfg(feature = "server")]
Sqlite(SqliteStore),
}
pub struct InMemoryStore {
jobs: HashMap<JobId, FaxJob>,
}
#[cfg(feature = "server")]
pub struct SqliteStore {
conn: Connection,
}
impl FaxQueue {
pub fn new_in_memory() -> Self {
FaxQueue::InMemory(InMemoryStore {
jobs: HashMap::new(),
})
}
#[cfg(feature = "server")]
pub fn new_sqlite(path: &Path) -> Result<Self> {
let conn = Connection::open(path)?;
conn.execute(
"CREATE TABLE IF NOT EXISTS fax_jobs (
id TEXT PRIMARY KEY,
recipient TEXT NOT NULL,
document_path TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'Queued',
pages INTEGER DEFAULT 0,
retries INTEGER DEFAULT 0,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)",
[],
)?;
Ok(FaxQueue::Sqlite(SqliteStore { conn }))
}
pub fn enqueue(&mut self, job: FaxJob) -> Result<()> {
match self {
FaxQueue::InMemory(s) => {
s.jobs.insert(job.id, job);
Ok(())
}
#[cfg(feature = "server")]
FaxQueue::Sqlite(s) => {
s.conn.execute(
"INSERT INTO fax_jobs (id, recipient, document_path, status, pages, retries, created_at, updated_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
rusqlite::params![
job.id.to_string(),
job.recipient,
job.document_path,
format!("{:?}", job.status),
job.pages,
job.retries,
job.created_at.to_rfc3339(),
job.updated_at.to_rfc3339(),
],
)?;
Ok(())
}
}
}
pub fn get(&self, id: &JobId) -> Option<FaxJob> {
match self {
FaxQueue::InMemory(s) => s.jobs.get(id).cloned(),
#[cfg(feature = "server")]
FaxQueue::Sqlite(_) => None, // TODO
}
}
pub fn update_status(&mut self, id: &JobId, status: JobStatus) -> Result<()> {
match self {
FaxQueue::InMemory(s) => {
if let Some(job) = s.jobs.get_mut(id) {
job.status = status;
job.updated_at = chrono::Utc::now();
}
Ok(())
}
#[cfg(feature = "server")]
FaxQueue::Sqlite(_s) => {
Ok(())
}
}
}
pub fn list(&self) -> Vec<FaxJob> {
match self {
FaxQueue::InMemory(s) => s.jobs.values().cloned().collect(),
#[cfg(feature = "server")]
FaxQueue::Sqlite(_) => Vec::new(),
}
}
pub fn pending_jobs(&self) -> Vec<FaxJob> {
match self {
FaxQueue::InMemory(s) => s
.jobs
.values()
.filter(|j| matches!(j.status, JobStatus::Queued))
.cloned()
.collect(),
#[cfg(feature = "server")]
FaxQueue::Sqlite(_) => Vec::new(),
}
}
}