From ede986080a538eced16490e47c638398c2e4c49f Mon Sep 17 00:00:00 2001 From: Jet Date: Thu, 19 Mar 2026 01:25:14 -0700 Subject: [PATCH] feat: update to match stalwart, and add onion --- api/Cargo.lock | 1 + api/Cargo.toml | 1 + api/src/email.rs | 9 +- api/src/handlers.rs | 232 +++++++++++++++++++++++++++++++------ api/src/serve.rs | 4 + flake.nix | 46 ++++++-- module.nix | 27 ++++- secrets/webhook-secret.age | 12 +- src/pages/home.ts | 1 + 9 files changed, 277 insertions(+), 56 deletions(-) diff --git a/api/Cargo.lock b/api/Cargo.lock index 0dea264..847bb85 100644 --- a/api/Cargo.lock +++ b/api/Cargo.lock @@ -567,6 +567,7 @@ name = "jetpham-qa-api" version = "0.1.0" dependencies = [ "axum", + "base64", "lettre", "rusqlite", "serde", diff --git a/api/Cargo.toml b/api/Cargo.toml index dc36f0d..a969a31 100644 --- a/api/Cargo.toml +++ b/api/Cargo.toml @@ -5,6 +5,7 @@ edition = "2021" [dependencies] axum = "0.8" +base64 = "0.22" lettre = "0.11" rusqlite = { version = "0.32", features = ["bundled"] } serde = { version = "1", features = ["derive"] } diff --git a/api/src/email.rs b/api/src/email.rs index efce595..e1f6573 100644 --- a/api/src/email.rs +++ b/api/src/email.rs @@ -6,6 +6,7 @@ pub fn send_notification( id: i64, question: &str, notify_email: &str, + mail_domain: &str, ) -> Result<(), Box> { let truncated = if question.len() > 50 { format!("{}...", &question[..50]) @@ -13,8 +14,8 @@ pub fn send_notification( question.to_string() }; - let from: Mailbox = "Q&A ".parse()?; - let reply_to: Mailbox = format!("qa+{id}@extremist.software").parse()?; + let from: Mailbox = format!("Q&A ").parse()?; + let reply_to: Mailbox = format!("qa+{id}@{mail_domain}").parse()?; let to: Mailbox = notify_email.parse()?; let email = Message::builder() @@ -26,7 +27,9 @@ pub fn send_notification( let mailer = SmtpTransport::builder_dangerous("localhost") .tls(Tls::None) - .hello_name(lettre::transport::smtp::extension::ClientId::Domain("extremist.software".to_string())) + .hello_name(lettre::transport::smtp::extension::ClientId::Domain( + mail_domain.to_string(), + )) .build(); mailer.send(&email)?; diff --git a/api/src/handlers.rs b/api/src/handlers.rs index 6fd8e3b..3b879e4 100644 --- a/api/src/handlers.rs +++ b/api/src/handlers.rs @@ -3,6 +3,7 @@ use std::sync::Arc; use axum::extract::State; use axum::http::{HeaderMap, StatusCode}; use axum::Json; +use base64::Engine; use serde::{Deserialize, Serialize}; use crate::email; @@ -20,7 +21,10 @@ pub struct Question { pub async fn get_questions( State(state): State>, ) -> Result>, StatusCode> { - let db = state.db.lock().map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + let db = state + .db + .lock() + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; let mut stmt = db .prepare( "SELECT id, question, answer, created_at, answered_at \ @@ -86,14 +90,20 @@ pub async fn post_question( "INSERT INTO questions (question) VALUES (?1)", rusqlite::params![body.question], ) - .map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "insert error".to_string()))?; + .map_err(|_| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + "insert error".to_string(), + ) + })?; db.last_insert_rowid() }; let notify_email = state.notify_email.clone(); + let mail_domain = state.mail_domain.clone(); let question_text = body.question.clone(); tokio::task::spawn_blocking(move || { - if let Err(e) = email::send_notification(id, &question_text, ¬ify_email) { + if let Err(e) = email::send_notification(id, &question_text, ¬ify_email, &mail_domain) { eprintln!("Failed to send notification: {e}"); } }); @@ -107,6 +117,10 @@ pub async fn post_question( pub struct MtaHookPayload { #[serde(default)] pub messages: Vec, + #[serde(default)] + pub envelope: Envelope, + #[serde(default)] + pub message: MtaHookBody, } #[derive(Deserialize)] @@ -114,13 +128,43 @@ pub struct MtaHookMessage { #[serde(default)] pub envelope: Envelope, #[serde(default)] + pub message: MtaHookBody, + #[serde(default)] pub contents: String, } #[derive(Deserialize, Default)] pub struct Envelope { #[serde(default)] - pub to: Vec, + pub to: Vec, +} + +#[derive(Deserialize)] +#[serde(untagged)] +pub enum Recipient { + Address(String), + WithAddress { address: String }, +} + +impl Default for Recipient { + fn default() -> Self { + Self::Address(String::new()) + } +} + +impl Recipient { + fn address(&self) -> &str { + match self { + Self::Address(address) => address, + Self::WithAddress { address } => address, + } + } +} + +#[derive(Deserialize, Default)] +pub struct MtaHookBody { + #[serde(default)] + pub contents: String, } #[derive(Serialize)] @@ -128,43 +172,92 @@ pub struct MtaHookResponse { pub action: &'static str, } +fn webhook_secret_matches(headers: &HeaderMap, expected_secret: &str) -> bool { + let header_secret = headers + .get("X-Webhook-Secret") + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + if header_secret == expected_secret { + return true; + } + + let auth_header = match headers.get(axum::http::header::AUTHORIZATION) { + Some(value) => value, + None => return false, + }; + let auth_header = match auth_header.to_str() { + Ok(value) => value, + Err(_) => return false, + }; + let encoded = match auth_header.strip_prefix("Basic ") { + Some(value) => value, + None => return false, + }; + let decoded = match base64::engine::general_purpose::STANDARD.decode(encoded) { + Ok(value) => value, + Err(_) => return false, + }; + let credentials = match std::str::from_utf8(&decoded) { + Ok(value) => value, + Err(_) => return false, + }; + let (_, password) = match credentials.split_once(':') { + Some(parts) => parts, + None => return false, + }; + + password == expected_secret +} + +fn extract_qa_reply(payload: &MtaHookPayload) -> Option<(i64, String)> { + if !payload.messages.is_empty() { + for message in &payload.messages { + if let Some(reply) = extract_qa_reply_from_message( + &message.envelope.to, + if message.message.contents.is_empty() { + &message.contents + } else { + &message.message.contents + }, + ) { + return Some(reply); + } + } + return None; + } + + extract_qa_reply_from_message(&payload.envelope.to, &payload.message.contents) +} + +fn extract_qa_reply_from_message( + recipients: &[Recipient], + contents: &str, +) -> Option<(i64, String)> { + let qa_recipient = recipients.iter().find(|recipient| { + let local = recipient.address().split('@').next().unwrap_or(""); + local.starts_with("qa+") + })?; + + let id = email::extract_id_from_address(qa_recipient.address()).ok()?; + let body = email::strip_quoted_text(contents); + if body.is_empty() { + return None; + } + + Some((id, body)) +} + pub async fn webhook( State(state): State>, headers: HeaderMap, Json(payload): Json, ) -> Result, (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 { + if !webhook_secret_matches(&headers, &state.webhook_secret) { + eprintln!("Rejected webhook: invalid secret"); return Err((StatusCode::UNAUTHORIZED, "invalid secret".to_string())); } - for message in &payload.messages { - // Find a qa+ 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; - } - + if let Some((id, body)) = extract_qa_reply(&payload) { let db = state .db .lock() @@ -176,9 +269,82 @@ pub async fn webhook( ) .map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "update error".to_string()))?; + eprintln!("Stored Q&A reply for question #{id}"); return Ok(Json(MtaHookResponse { action: "discard" })); } // No Q&A recipient matched — let Stalwart deliver normally Ok(Json(MtaHookResponse { action: "accept" })) } + +#[cfg(test)] +mod tests { + use axum::http::HeaderMap; + + use super::{extract_qa_reply, webhook_secret_matches, MtaHookPayload}; + + #[test] + fn extracts_reply_from_current_stalwart_payload() { + let payload: MtaHookPayload = serde_json::from_str( + r#"{ + "envelope": { + "to": [ + { + "address": "qa+42@extremist.software" + } + ] + }, + "message": { + "contents": "This is the answer.\n\nOn earlier mail wrote:\n> quoted" + } + }"#, + ) + .unwrap(); + + assert_eq!( + extract_qa_reply(&payload), + Some((42, "This is the answer.".to_string())) + ); + } + + #[test] + fn extracts_reply_from_legacy_batch_payload() { + let payload: MtaHookPayload = serde_json::from_str( + r#"{ + "messages": [ + { + "envelope": { + "to": ["qa+7@extremist.software"] + }, + "contents": "Legacy answer" + } + ] + }"#, + ) + .unwrap(); + + assert_eq!( + extract_qa_reply(&payload), + Some((7, "Legacy answer".to_string())) + ); + } + + #[test] + fn accepts_header_secret() { + let mut headers = HeaderMap::new(); + headers.insert("X-Webhook-Secret", "topsecret".parse().unwrap()); + + assert!(webhook_secret_matches(&headers, "topsecret")); + } + + #[test] + fn accepts_basic_auth_password() { + let mut headers = HeaderMap::new(); + headers.insert( + axum::http::header::AUTHORIZATION, + "Basic dXNlcjp0b3BzZWNyZXQ=".parse().unwrap(), + ); + + assert!(webhook_secret_matches(&headers, "topsecret")); + } +} diff --git a/api/src/serve.rs b/api/src/serve.rs index 6dccfa9..d5e03bb 100644 --- a/api/src/serve.rs +++ b/api/src/serve.rs @@ -11,6 +11,7 @@ use crate::rate_limit::RateLimiter; pub struct AppState { pub db: Mutex, pub notify_email: String, + pub mail_domain: String, pub rate_limiter: RateLimiter, pub webhook_secret: String, } @@ -18,6 +19,8 @@ pub struct AppState { pub async fn run() -> Result<(), Box> { let db_path = std::env::var("QA_DB_PATH").unwrap_or_else(|_| "qa.db".to_string()); let notify_email = std::env::var("QA_NOTIFY_EMAIL").expect("QA_NOTIFY_EMAIL must be set"); + let mail_domain = + std::env::var("QA_MAIL_DOMAIN").unwrap_or_else(|_| "extremist.software".to_string()); let webhook_secret = std::env::var("WEBHOOK_SECRET").expect("WEBHOOK_SECRET must be set"); let conn = Connection::open(&db_path)?; @@ -35,6 +38,7 @@ pub async fn run() -> Result<(), Box> { let state = Arc::new(AppState { db: Mutex::new(conn), notify_email, + mail_domain, rate_limiter: RateLimiter::new(5, 3600), webhook_secret, }); diff --git a/flake.nix b/flake.nix index 17cac29..2dff2f3 100644 --- a/flake.nix +++ b/flake.nix @@ -7,16 +7,27 @@ agenix.url = "github:ryantm/agenix"; agenix.inputs.nixpkgs.follows = "nixpkgs"; }; - outputs = { self, nixpkgs, rust-overlay, flake-utils, agenix }: - (flake-utils.lib.eachDefaultSystem (system: + outputs = + { + self, + nixpkgs, + rust-overlay, + flake-utils, + agenix, + }: + (flake-utils.lib.eachDefaultSystem ( + system: let overlays = [ (import rust-overlay) ]; pkgs = import nixpkgs { inherit system overlays; }; - rustToolchain = pkgs.rust-bin.selectLatestNightlyWith (toolchain: + agenixPkg = agenix.packages.${system}.default; + rustToolchain = pkgs.rust-bin.selectLatestNightlyWith ( + toolchain: toolchain.default.override { extensions = [ "rust-src" ]; targets = [ "wasm32-unknown-unknown" ]; - }); + } + ); # Stage 1: Build the WASM crate (FOD — network allowed, output content-addressed) cgol-wasm = pkgs.stdenv.mkDerivation { @@ -24,7 +35,12 @@ version = "0.1.0"; src = ./cgol; - nativeBuildInputs = [ rustToolchain pkgs.wasm-pack pkgs.binaryen pkgs.cacert ]; + nativeBuildInputs = [ + rustToolchain + pkgs.wasm-pack + pkgs.binaryen + pkgs.cacert + ]; buildPhase = '' export HOME=$TMPDIR @@ -83,12 +99,13 @@ pname = "jetpham-qa-api"; version = "0.1.0"; src = ./api; - cargoHash = "sha256-/EgiCn5N3E1tCcBWI3Sm3NGQt2h8l8yPwi/ZjporiVs="; + cargoHash = "sha256-PL5D3NtPFZcDIxf8f2EOT7fahKVgt/+7obJIdR17AUY="; nativeBuildInputs = [ pkgs.pkg-config ]; buildInputs = [ pkgs.openssl ]; }; - in { + in + { packages = { default = website; cgol-wasm = cgol-wasm; @@ -96,12 +113,21 @@ }; devShells.default = pkgs.mkShell { buildInputs = with pkgs; [ - nodejs git curl typescript-language-server - pkg-config wasm-pack binaryen rustToolchain + nodejs + git + curl + openssl + agenixPkg + typescript-language-server + pkg-config + wasm-pack + binaryen + rustToolchain ]; }; } - )) // { + )) + // { nixosModules.default = import ./module.nix self; }; } diff --git a/module.nix b/module.nix index bfb4f52..dc79c38 100644 --- a/module.nix +++ b/module.nix @@ -5,6 +5,11 @@ let cfg = config.services.jetpham-website; package = self.packages.x86_64-linux.default; qaApi = self.packages.x86_64-linux.qa-api; + webhookSecretPath = + if cfg.webhookSecretFile != null then + cfg.webhookSecretFile + else + config.age.secrets.webhook-secret.path; in { options.services.jetpham-website = { @@ -38,7 +43,7 @@ in }; config = lib.mkIf cfg.enable { - age.secrets.webhook-secret = { + age.secrets.webhook-secret = lib.mkIf (cfg.webhookSecretFile == null) { file = "${self}/secrets/webhook-secret.age"; mode = "0400"; }; @@ -65,7 +70,15 @@ in services.tor = lib.mkIf cfg.tor.enable { enable = true; relay.onionServices.jetpham-website = { - map = [{ port = 80; target = { addr = "127.0.0.1"; port = 8888; }; }]; + map = [ + { + port = 80; + target = { + addr = "127.0.0.1"; + port = 8888; + }; + } + ]; }; }; @@ -98,13 +111,19 @@ in Environment = [ "QA_DB_PATH=/var/lib/jetpham-qa/qa.db" "QA_NOTIFY_EMAIL=${cfg.qaNotifyEmail}" + "QA_MAIL_DOMAIN=${cfg.qaMailDomain}" ]; Restart = "on-failure"; RestartSec = 5; - LoadCredential = "webhook-secret:${config.age.secrets.webhook-secret.path}"; + LoadCredential = "webhook-secret:${webhookSecretPath}"; }; script = '' - export WEBHOOK_SECRET="$(cat $CREDENTIALS_DIRECTORY/webhook-secret)" + if [ ! -s "$CREDENTIALS_DIRECTORY/webhook-secret" ]; then + echo "WEBHOOK_SECRET credential is empty" >&2 + exit 1 + fi + + export WEBHOOK_SECRET="$(cat "$CREDENTIALS_DIRECTORY/webhook-secret")" exec ${qaApi}/bin/jetpham-qa-api ''; }; diff --git a/secrets/webhook-secret.age b/secrets/webhook-secret.age index b40ad99..1da3f60 100644 --- a/secrets/webhook-secret.age +++ b/secrets/webhook-secret.age @@ -1,7 +1,7 @@ age-encryption.org/v1 --> ssh-ed25519 Ziw7aw zDI2q+wM70YbQFw7hEQbU58XrgkWnM8fura+ovG0bTw -0Aznts9gOT8ADxFY0SIv/VT0ExEEq9wZLTnlC0j15JU --> ssh-ed25519 uKftJg dFQxnrMwQHJ2lVBMi6P4XugljREjAHPV9bEFmLhuiBc -VWKLaM3peiELoJigapdZv0N4Z4lRZZ8eGMEFy+WtdxA ---- zJghsuTUoVH8bKynqq29kzEuNDuEjvFf5v/s98jQdaA -+iV2 *HФƀ c \ No newline at end of file +-> ssh-ed25519 Ziw7aw Wef4V3554wa7yF3ztMeKxqxgI4sb8MYF7x7GFj+XhFc +GcqWkuplBIelOaP1cvOqwyK6igK5MAKPUqvpxCaV/Yk +-> ssh-ed25519 uKftJg Tnhs9FR2j2713OO4qDwWb4ERNivqmKI8tN45Av1hzTU +bWXnkDFehYUr3AghaUV4wYKfQEOqJsZC/SL2DUcq3DM +--- eBpn66oOUDpku3NMzF+30j2uC/iyzO3Oy3lftTQM8MY +IQ'1\R BPźn_pn>k&X ˂=RL:d6A }WK \ No newline at end of file diff --git a/src/pages/home.ts b/src/pages/home.ts index 4afc83f..f6befdd 100644 --- a/src/pages/home.ts +++ b/src/pages/home.ts @@ -33,6 +33,7 @@ export function homePage(outlet: HTMLElement) {
  • GitHub
  • X
  • Bluesky
  • +
  • .onion
  • `)}