feat: update to match stalwart, and add onion

This commit is contained in:
Jet 2026-03-19 01:25:14 -07:00
parent 55a862fabb
commit ede986080a
No known key found for this signature in database
9 changed files with 277 additions and 56 deletions

1
api/Cargo.lock generated
View file

@ -567,6 +567,7 @@ name = "jetpham-qa-api"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"axum", "axum",
"base64",
"lettre", "lettre",
"rusqlite", "rusqlite",
"serde", "serde",

View file

@ -5,6 +5,7 @@ edition = "2021"
[dependencies] [dependencies]
axum = "0.8" axum = "0.8"
base64 = "0.22"
lettre = "0.11" lettre = "0.11"
rusqlite = { version = "0.32", features = ["bundled"] } rusqlite = { version = "0.32", features = ["bundled"] }
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }

View file

@ -6,6 +6,7 @@ pub fn send_notification(
id: i64, id: i64,
question: &str, question: &str,
notify_email: &str, notify_email: &str,
mail_domain: &str,
) -> Result<(), Box<dyn std::error::Error>> { ) -> Result<(), Box<dyn std::error::Error>> {
let truncated = if question.len() > 50 { let truncated = if question.len() > 50 {
format!("{}...", &question[..50]) format!("{}...", &question[..50])
@ -13,8 +14,8 @@ pub fn send_notification(
question.to_string() question.to_string()
}; };
let from: Mailbox = "Q&A <qa@extremist.software>".parse()?; let from: Mailbox = format!("Q&A <qa@{mail_domain}>").parse()?;
let reply_to: Mailbox = format!("qa+{id}@extremist.software").parse()?; let reply_to: Mailbox = format!("qa+{id}@{mail_domain}").parse()?;
let to: Mailbox = notify_email.parse()?; let to: Mailbox = notify_email.parse()?;
let email = Message::builder() let email = Message::builder()
@ -26,7 +27,9 @@ pub fn send_notification(
let mailer = SmtpTransport::builder_dangerous("localhost") let mailer = SmtpTransport::builder_dangerous("localhost")
.tls(Tls::None) .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(); .build();
mailer.send(&email)?; mailer.send(&email)?;

View file

@ -3,6 +3,7 @@ use std::sync::Arc;
use axum::extract::State; use axum::extract::State;
use axum::http::{HeaderMap, StatusCode}; use axum::http::{HeaderMap, StatusCode};
use axum::Json; use axum::Json;
use base64::Engine;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::email; use crate::email;
@ -20,7 +21,10 @@ pub struct Question {
pub async fn get_questions( pub async fn get_questions(
State(state): State<Arc<AppState>>, State(state): State<Arc<AppState>>,
) -> Result<Json<Vec<Question>>, StatusCode> { ) -> Result<Json<Vec<Question>>, 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 let mut stmt = db
.prepare( .prepare(
"SELECT id, question, answer, created_at, answered_at \ "SELECT id, question, answer, created_at, answered_at \
@ -86,14 +90,20 @@ pub async fn post_question(
"INSERT INTO questions (question) VALUES (?1)", "INSERT INTO questions (question) VALUES (?1)",
rusqlite::params![body.question], 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() db.last_insert_rowid()
}; };
let notify_email = state.notify_email.clone(); let notify_email = state.notify_email.clone();
let mail_domain = state.mail_domain.clone();
let question_text = body.question.clone(); let question_text = body.question.clone();
tokio::task::spawn_blocking(move || { tokio::task::spawn_blocking(move || {
if let Err(e) = email::send_notification(id, &question_text, &notify_email) { if let Err(e) = email::send_notification(id, &question_text, &notify_email, &mail_domain) {
eprintln!("Failed to send notification: {e}"); eprintln!("Failed to send notification: {e}");
} }
}); });
@ -107,6 +117,10 @@ pub async fn post_question(
pub struct MtaHookPayload { pub struct MtaHookPayload {
#[serde(default)] #[serde(default)]
pub messages: Vec<MtaHookMessage>, pub messages: Vec<MtaHookMessage>,
#[serde(default)]
pub envelope: Envelope,
#[serde(default)]
pub message: MtaHookBody,
} }
#[derive(Deserialize)] #[derive(Deserialize)]
@ -114,13 +128,43 @@ pub struct MtaHookMessage {
#[serde(default)] #[serde(default)]
pub envelope: Envelope, pub envelope: Envelope,
#[serde(default)] #[serde(default)]
pub message: MtaHookBody,
#[serde(default)]
pub contents: String, pub contents: String,
} }
#[derive(Deserialize, Default)] #[derive(Deserialize, Default)]
pub struct Envelope { pub struct Envelope {
#[serde(default)] #[serde(default)]
pub to: Vec<String>, pub to: Vec<Recipient>,
}
#[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)] #[derive(Serialize)]
@ -128,43 +172,92 @@ pub struct MtaHookResponse {
pub action: &'static str, 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( pub async fn webhook(
State(state): State<Arc<AppState>>, State(state): State<Arc<AppState>>,
headers: HeaderMap, headers: HeaderMap,
Json(payload): Json<MtaHookPayload>, Json(payload): Json<MtaHookPayload>,
) -> Result<Json<MtaHookResponse>, (StatusCode, String)> { ) -> Result<Json<MtaHookResponse>, (StatusCode, String)> {
// Verify webhook secret if !webhook_secret_matches(&headers, &state.webhook_secret) {
let secret = headers eprintln!("Rejected webhook: invalid secret");
.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())); return Err((StatusCode::UNAUTHORIZED, "invalid secret".to_string()));
} }
for message in &payload.messages { if let Some((id, body)) = extract_qa_reply(&payload) {
// 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 let db = state
.db .db
.lock() .lock()
@ -176,9 +269,82 @@ pub async fn webhook(
) )
.map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "update error".to_string()))?; .map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "update error".to_string()))?;
eprintln!("Stored Q&A reply for question #{id}");
return Ok(Json(MtaHookResponse { action: "discard" })); return Ok(Json(MtaHookResponse { action: "discard" }));
} }
// No Q&A recipient matched — let Stalwart deliver normally // No Q&A recipient matched — let Stalwart deliver normally
Ok(Json(MtaHookResponse { action: "accept" })) 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"));
}
}

View file

@ -11,6 +11,7 @@ use crate::rate_limit::RateLimiter;
pub struct AppState { pub struct AppState {
pub db: Mutex<Connection>, pub db: Mutex<Connection>,
pub notify_email: String, pub notify_email: String,
pub mail_domain: String,
pub rate_limiter: RateLimiter, pub rate_limiter: RateLimiter,
pub webhook_secret: String, pub webhook_secret: String,
} }
@ -18,6 +19,8 @@ pub struct AppState {
pub async fn run() -> Result<(), Box<dyn std::error::Error>> { pub async fn run() -> Result<(), Box<dyn std::error::Error>> {
let db_path = std::env::var("QA_DB_PATH").unwrap_or_else(|_| "qa.db".to_string()); 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 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 webhook_secret = std::env::var("WEBHOOK_SECRET").expect("WEBHOOK_SECRET must be set");
let conn = Connection::open(&db_path)?; let conn = Connection::open(&db_path)?;
@ -35,6 +38,7 @@ pub async fn run() -> Result<(), Box<dyn std::error::Error>> {
let state = Arc::new(AppState { let state = Arc::new(AppState {
db: Mutex::new(conn), db: Mutex::new(conn),
notify_email, notify_email,
mail_domain,
rate_limiter: RateLimiter::new(5, 3600), rate_limiter: RateLimiter::new(5, 3600),
webhook_secret, webhook_secret,
}); });

View file

@ -7,16 +7,27 @@
agenix.url = "github:ryantm/agenix"; agenix.url = "github:ryantm/agenix";
agenix.inputs.nixpkgs.follows = "nixpkgs"; agenix.inputs.nixpkgs.follows = "nixpkgs";
}; };
outputs = { self, nixpkgs, rust-overlay, flake-utils, agenix }: outputs =
(flake-utils.lib.eachDefaultSystem (system: {
self,
nixpkgs,
rust-overlay,
flake-utils,
agenix,
}:
(flake-utils.lib.eachDefaultSystem (
system:
let let
overlays = [ (import rust-overlay) ]; overlays = [ (import rust-overlay) ];
pkgs = import nixpkgs { inherit system overlays; }; 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 { toolchain.default.override {
extensions = [ "rust-src" ]; extensions = [ "rust-src" ];
targets = [ "wasm32-unknown-unknown" ]; targets = [ "wasm32-unknown-unknown" ];
}); }
);
# Stage 1: Build the WASM crate (FOD — network allowed, output content-addressed) # Stage 1: Build the WASM crate (FOD — network allowed, output content-addressed)
cgol-wasm = pkgs.stdenv.mkDerivation { cgol-wasm = pkgs.stdenv.mkDerivation {
@ -24,7 +35,12 @@
version = "0.1.0"; version = "0.1.0";
src = ./cgol; src = ./cgol;
nativeBuildInputs = [ rustToolchain pkgs.wasm-pack pkgs.binaryen pkgs.cacert ]; nativeBuildInputs = [
rustToolchain
pkgs.wasm-pack
pkgs.binaryen
pkgs.cacert
];
buildPhase = '' buildPhase = ''
export HOME=$TMPDIR export HOME=$TMPDIR
@ -83,12 +99,13 @@
pname = "jetpham-qa-api"; pname = "jetpham-qa-api";
version = "0.1.0"; version = "0.1.0";
src = ./api; src = ./api;
cargoHash = "sha256-/EgiCn5N3E1tCcBWI3Sm3NGQt2h8l8yPwi/ZjporiVs="; cargoHash = "sha256-PL5D3NtPFZcDIxf8f2EOT7fahKVgt/+7obJIdR17AUY=";
nativeBuildInputs = [ pkgs.pkg-config ]; nativeBuildInputs = [ pkgs.pkg-config ];
buildInputs = [ pkgs.openssl ]; buildInputs = [ pkgs.openssl ];
}; };
in { in
{
packages = { packages = {
default = website; default = website;
cgol-wasm = cgol-wasm; cgol-wasm = cgol-wasm;
@ -96,12 +113,21 @@
}; };
devShells.default = pkgs.mkShell { devShells.default = pkgs.mkShell {
buildInputs = with pkgs; [ buildInputs = with pkgs; [
nodejs git curl typescript-language-server nodejs
pkg-config wasm-pack binaryen rustToolchain git
curl
openssl
agenixPkg
typescript-language-server
pkg-config
wasm-pack
binaryen
rustToolchain
]; ];
}; };
} }
)) // { ))
// {
nixosModules.default = import ./module.nix self; nixosModules.default = import ./module.nix self;
}; };
} }

View file

@ -5,6 +5,11 @@ let
cfg = config.services.jetpham-website; cfg = config.services.jetpham-website;
package = self.packages.x86_64-linux.default; package = self.packages.x86_64-linux.default;
qaApi = self.packages.x86_64-linux.qa-api; qaApi = self.packages.x86_64-linux.qa-api;
webhookSecretPath =
if cfg.webhookSecretFile != null then
cfg.webhookSecretFile
else
config.age.secrets.webhook-secret.path;
in in
{ {
options.services.jetpham-website = { options.services.jetpham-website = {
@ -38,7 +43,7 @@ in
}; };
config = lib.mkIf cfg.enable { config = lib.mkIf cfg.enable {
age.secrets.webhook-secret = { age.secrets.webhook-secret = lib.mkIf (cfg.webhookSecretFile == null) {
file = "${self}/secrets/webhook-secret.age"; file = "${self}/secrets/webhook-secret.age";
mode = "0400"; mode = "0400";
}; };
@ -65,7 +70,15 @@ in
services.tor = lib.mkIf cfg.tor.enable { services.tor = lib.mkIf cfg.tor.enable {
enable = true; enable = true;
relay.onionServices.jetpham-website = { 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 = [ Environment = [
"QA_DB_PATH=/var/lib/jetpham-qa/qa.db" "QA_DB_PATH=/var/lib/jetpham-qa/qa.db"
"QA_NOTIFY_EMAIL=${cfg.qaNotifyEmail}" "QA_NOTIFY_EMAIL=${cfg.qaNotifyEmail}"
"QA_MAIL_DOMAIN=${cfg.qaMailDomain}"
]; ];
Restart = "on-failure"; Restart = "on-failure";
RestartSec = 5; RestartSec = 5;
LoadCredential = "webhook-secret:${config.age.secrets.webhook-secret.path}"; LoadCredential = "webhook-secret:${webhookSecretPath}";
}; };
script = '' 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 exec ${qaApi}/bin/jetpham-qa-api
''; '';
}; };

View file

@ -1,7 +1,7 @@
age-encryption.org/v1 age-encryption.org/v1
-> ssh-ed25519 Ziw7aw zDI2q+wM70YbQFw7hEQbU58XrgkWnM8fura+ovG0bTw -> ssh-ed25519 Ziw7aw Wef4V3554wa7yF3ztMeKxqxgI4sb8MYF7x7GFj+XhFc
0Aznts9gOT8ADxFY0SIv/VT0ExEEq9wZLTnlC0j15JU GcqWkuplBIelOaP1cvOqwyK6igK5MAKPUqvpxCaV/Yk
-> ssh-ed25519 uKftJg dFQxnrMwQHJ2lVBMi6P4XugljREjAHPV9bEFmLhuiBc -> ssh-ed25519 uKftJg Tnhs9FR2j2713OO4qDwWb4ERNivqmKI8tN45Av1hzTU
VWKLaM3peiELoJigapdZv0N4Z4lRZZ8eGMEFy+WtdxA bWXnkDFehYUr3AghaUV4wYKfQEOqJsZC/SL2DUcq3DM
--- zJghsuTUoVH8bKynqq29kzEuNDuEjvFf5v/s98jQdaA --- eBpn66oOUDpku3NMzF+30j2uC/iyzO3Oy3lftTQM8MY
𡂝濮+<2B>i籤V2 *H儑齪<E58491><E9BDAA> c栛 ¡öî™ÙI®í£Q'ã1\óùËÄRç BP®Åºnµ_pøn„>¬Œƒ“·²—k&Xå<58> Ë‚=RL:d6A ¨}ÜÒäÎWóK

View file

@ -33,6 +33,7 @@ export function homePage(outlet: HTMLElement) {
<li><a href="https://github.com/jetpham" class="inline-flex items-center">GitHub</a></li> <li><a href="https://github.com/jetpham" class="inline-flex items-center">GitHub</a></li>
<li><a href="https://x.com/exmistsoftware" class="inline-flex items-center">X</a></li> <li><a href="https://x.com/exmistsoftware" class="inline-flex items-center">X</a></li>
<li><a href="https://bsky.app/profile/extremist.software" class="inline-flex items-center">Bluesky</a></li> <li><a href="https://bsky.app/profile/extremist.software" class="inline-flex items-center">Bluesky</a></li>
<li><a href="http://jet7tetd43snvjx3ng5jrhuwm2yhyp76tjtct5mtofg64apokcgq7fqd.onion" class="inline-flex items-center">.onion</a></li>
</ol> </ol>
</fieldset> </fieldset>
`)} `)}