feat: create mymx service
This commit is contained in:
parent
478af69792
commit
ad8cb52169
20 changed files with 3152 additions and 1 deletions
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -11,4 +11,6 @@ dkim_private.pem
|
||||||
|
|
||||||
install.log
|
install.log
|
||||||
.direnv
|
.direnv
|
||||||
.agents
|
.agents
|
||||||
|
services/mymx/target/
|
||||||
|
services/mymx/.direnv
|
||||||
|
|
@ -11,6 +11,7 @@
|
||||||
./modules/monitoring.nix
|
./modules/monitoring.nix
|
||||||
./modules/ntfy.nix
|
./modules/ntfy.nix
|
||||||
./modules/uptime-kuma.nix
|
./modules/uptime-kuma.nix
|
||||||
|
./modules/mymx.nix
|
||||||
./secrets/secrets-scheme.nix
|
./secrets/secrets-scheme.nix
|
||||||
# Impure Secrets
|
# Impure Secrets
|
||||||
./secrets/secrets.nix
|
./secrets/secrets.nix
|
||||||
|
|
|
||||||
39
flake.lock
generated
39
flake.lock
generated
|
|
@ -20,6 +20,23 @@
|
||||||
"type": "github"
|
"type": "github"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"mymx": {
|
||||||
|
"inputs": {
|
||||||
|
"nixpkgs": [
|
||||||
|
"nixpkgs"
|
||||||
|
],
|
||||||
|
"rust-overlay": "rust-overlay"
|
||||||
|
},
|
||||||
|
"locked": {
|
||||||
|
"path": "./services/mymx",
|
||||||
|
"type": "path"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"path": "./services/mymx",
|
||||||
|
"type": "path"
|
||||||
|
},
|
||||||
|
"parent": []
|
||||||
|
},
|
||||||
"nixpkgs": {
|
"nixpkgs": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1772198003,
|
"lastModified": 1772198003,
|
||||||
|
|
@ -39,8 +56,30 @@
|
||||||
"root": {
|
"root": {
|
||||||
"inputs": {
|
"inputs": {
|
||||||
"disko": "disko",
|
"disko": "disko",
|
||||||
|
"mymx": "mymx",
|
||||||
"nixpkgs": "nixpkgs"
|
"nixpkgs": "nixpkgs"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"rust-overlay": {
|
||||||
|
"inputs": {
|
||||||
|
"nixpkgs": [
|
||||||
|
"mymx",
|
||||||
|
"nixpkgs"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1772593411,
|
||||||
|
"narHash": "sha256-47WOnCSyOL6AghZiMIJaTLWM359DHe3be9R1cNCdGUE=",
|
||||||
|
"owner": "oxalica",
|
||||||
|
"repo": "rust-overlay",
|
||||||
|
"rev": "a741b36b77440f5db15fcf2ab6d7d592d2f9ee8f",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "oxalica",
|
||||||
|
"repo": "rust-overlay",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"root": "root",
|
"root": "root",
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,9 @@
|
||||||
disko.url = "github:nix-community/disko";
|
disko.url = "github:nix-community/disko";
|
||||||
disko.inputs.nixpkgs.follows = "nixpkgs";
|
disko.inputs.nixpkgs.follows = "nixpkgs";
|
||||||
|
|
||||||
|
mymx.url = "path:./services/mymx";
|
||||||
|
mymx.inputs.nixpkgs.follows = "nixpkgs";
|
||||||
|
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -76,6 +76,12 @@
|
||||||
'';
|
'';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
"mymx.extremist.software" = {
|
||||||
|
extraConfig = ''
|
||||||
|
reverse_proxy localhost:4002
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
|
||||||
"matrix.extremist.software" = {
|
"matrix.extremist.software" = {
|
||||||
extraConfig = ''
|
extraConfig = ''
|
||||||
reverse_proxy /_matrix/* 127.0.0.1:8008
|
reverse_proxy /_matrix/* 127.0.0.1:8008
|
||||||
|
|
|
||||||
40
modules/mymx.nix
Normal file
40
modules/mymx.nix
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
{ config, pkgs, inputs, ... }:
|
||||||
|
|
||||||
|
{
|
||||||
|
users.users.mymx = {
|
||||||
|
isSystemUser = true;
|
||||||
|
group = "mymx";
|
||||||
|
description = "MyMX webhook service user";
|
||||||
|
};
|
||||||
|
users.groups.mymx = {};
|
||||||
|
|
||||||
|
services.postgresql = {
|
||||||
|
enable = true;
|
||||||
|
ensureDatabases = [ "mymx" ];
|
||||||
|
ensureUsers = [{
|
||||||
|
name = "mymx";
|
||||||
|
ensureDBOwnership = true;
|
||||||
|
}];
|
||||||
|
};
|
||||||
|
|
||||||
|
systemd.services.mymx = {
|
||||||
|
description = "MyMX Webhook Receiver";
|
||||||
|
after = [ "postgresql.service" "network.target" ];
|
||||||
|
requires = [ "postgresql.service" ];
|
||||||
|
wantedBy = [ "multi-user.target" ];
|
||||||
|
|
||||||
|
environment = {
|
||||||
|
DATABASE_URL = "postgres:///mymx?host=/run/postgresql";
|
||||||
|
LISTEN_ADDR = "127.0.0.1:4002";
|
||||||
|
MYMX_WEBHOOK_SECRET = config.mySecrets.mymxWebhookSecret;
|
||||||
|
};
|
||||||
|
|
||||||
|
serviceConfig = {
|
||||||
|
ExecStart = "${inputs.mymx.packages.x86_64-linux.default}/bin/mymx-server";
|
||||||
|
User = "mymx";
|
||||||
|
Group = "mymx";
|
||||||
|
Restart = "on-failure";
|
||||||
|
RestartSec = 5;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -37,5 +37,9 @@ with lib;
|
||||||
type = types.str;
|
type = types.str;
|
||||||
description = "Bcrypt hash for ntfy admin user";
|
description = "Bcrypt hash for ntfy admin user";
|
||||||
};
|
};
|
||||||
|
mymxWebhookSecret = mkOption {
|
||||||
|
type = types.str;
|
||||||
|
description = "MyMX Webhook Secret for signature verification";
|
||||||
|
};
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,5 +11,6 @@
|
||||||
sshPublicKey = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAA...";
|
sshPublicKey = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAA...";
|
||||||
matrixMacaroon = "changeme_matrix_macaroon_secret_key";
|
matrixMacaroon = "changeme_matrix_macaroon_secret_key";
|
||||||
ntfyAdminHash = "changeme_bcrypt_hash_from_ntfy_user_hash";
|
ntfyAdminHash = "changeme_bcrypt_hash_from_ntfy_user_hash";
|
||||||
|
mymxWebhookSecret = "changeme_mymx_webhook_secret";
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
1
services/mymx/.envrc
Normal file
1
services/mymx/.envrc
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
use flake
|
||||||
2
services/mymx/.gitignore
vendored
Normal file
2
services/mymx/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
target/
|
||||||
|
.direnv
|
||||||
2349
services/mymx/Cargo.lock
generated
Normal file
2349
services/mymx/Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
3
services/mymx/Cargo.toml
Normal file
3
services/mymx/Cargo.toml
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
[workspace]
|
||||||
|
resolver = "2"
|
||||||
|
members = ["mymx-sdk", "mymx-server"]
|
||||||
48
services/mymx/flake.lock
generated
Normal file
48
services/mymx/flake.lock
generated
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
{
|
||||||
|
"nodes": {
|
||||||
|
"nixpkgs": {
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1772542754,
|
||||||
|
"narHash": "sha256-WGV2hy+VIeQsYXpsLjdr4GvHv5eECMISX1zKLTedhdg=",
|
||||||
|
"owner": "nixos",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"rev": "8c809a146a140c5c8806f13399592dbcb1bb5dc4",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "nixos",
|
||||||
|
"ref": "nixos-unstable",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"root": {
|
||||||
|
"inputs": {
|
||||||
|
"nixpkgs": "nixpkgs",
|
||||||
|
"rust-overlay": "rust-overlay"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"rust-overlay": {
|
||||||
|
"inputs": {
|
||||||
|
"nixpkgs": [
|
||||||
|
"nixpkgs"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1772593411,
|
||||||
|
"narHash": "sha256-47WOnCSyOL6AghZiMIJaTLWM359DHe3be9R1cNCdGUE=",
|
||||||
|
"owner": "oxalica",
|
||||||
|
"repo": "rust-overlay",
|
||||||
|
"rev": "a741b36b77440f5db15fcf2ab6d7d592d2f9ee8f",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "oxalica",
|
||||||
|
"repo": "rust-overlay",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"root": "root",
|
||||||
|
"version": 7
|
||||||
|
}
|
||||||
40
services/mymx/flake.nix
Normal file
40
services/mymx/flake.nix
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
{
|
||||||
|
description = "MyMX webhook receiver service";
|
||||||
|
|
||||||
|
inputs = {
|
||||||
|
nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable";
|
||||||
|
rust-overlay.url = "github:oxalica/rust-overlay";
|
||||||
|
rust-overlay.inputs.nixpkgs.follows = "nixpkgs";
|
||||||
|
};
|
||||||
|
|
||||||
|
outputs = { self, nixpkgs, rust-overlay, ... }:
|
||||||
|
let
|
||||||
|
system = "x86_64-linux";
|
||||||
|
pkgs = import nixpkgs {
|
||||||
|
inherit system;
|
||||||
|
overlays = [ rust-overlay.overlays.default ];
|
||||||
|
};
|
||||||
|
rustToolchain = pkgs.rust-bin.nightly.latest.default;
|
||||||
|
in
|
||||||
|
{
|
||||||
|
packages.${system}.default = pkgs.rustPlatform.buildRustPackage {
|
||||||
|
pname = "mymx-server";
|
||||||
|
version = "0.1.0";
|
||||||
|
src = ./.;
|
||||||
|
cargoLock.lockFile = ./Cargo.lock;
|
||||||
|
nativeBuildInputs = [ pkgs.pkg-config ];
|
||||||
|
buildInputs = [ pkgs.openssl ];
|
||||||
|
env.SQLX_OFFLINE = "true";
|
||||||
|
};
|
||||||
|
|
||||||
|
devShells.${system}.default = pkgs.mkShell {
|
||||||
|
buildInputs = [
|
||||||
|
rustToolchain
|
||||||
|
pkgs.postgresql
|
||||||
|
pkgs.pkg-config
|
||||||
|
pkgs.openssl
|
||||||
|
pkgs.sqlx-cli
|
||||||
|
];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
14
services/mymx/mymx-sdk/Cargo.toml
Normal file
14
services/mymx/mymx-sdk/Cargo.toml
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
[package]
|
||||||
|
name = "mymx-sdk"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
|
hmac = "0.12"
|
||||||
|
sha2 = "0.10"
|
||||||
|
hex = "0.4"
|
||||||
|
subtle = "2"
|
||||||
|
thiserror = "2"
|
||||||
|
chrono = { version = "0.4", features = ["serde"] }
|
||||||
274
services/mymx/mymx-sdk/src/lib.rs
Normal file
274
services/mymx/mymx-sdk/src/lib.rs
Normal file
|
|
@ -0,0 +1,274 @@
|
||||||
|
use chrono::Utc;
|
||||||
|
use hmac::{Hmac, Mac};
|
||||||
|
use serde::Deserialize;
|
||||||
|
use sha2::Sha256;
|
||||||
|
use subtle::ConstantTimeEq;
|
||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
type HmacSha256 = Hmac<Sha256>;
|
||||||
|
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
pub enum Error {
|
||||||
|
#[error("invalid signature header format")]
|
||||||
|
InvalidSignatureHeader,
|
||||||
|
#[error("signature verification failed")]
|
||||||
|
SignatureVerificationFailed,
|
||||||
|
#[error("timestamp too old (replay protection)")]
|
||||||
|
TimestampTooOld,
|
||||||
|
#[error("failed to parse webhook body: {0}")]
|
||||||
|
ParseError(#[from] serde_json::Error),
|
||||||
|
#[error("HMAC error: {0}")]
|
||||||
|
HmacError(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct EmailReceivedEvent {
|
||||||
|
pub id: String,
|
||||||
|
pub event: String,
|
||||||
|
pub version: String,
|
||||||
|
pub delivery: Delivery,
|
||||||
|
pub email: Email,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct Delivery {
|
||||||
|
pub endpoint_id: String,
|
||||||
|
pub attempt: u32,
|
||||||
|
pub attempted_at: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct Email {
|
||||||
|
pub id: String,
|
||||||
|
pub received_at: String,
|
||||||
|
pub smtp: SmtpEnvelope,
|
||||||
|
pub headers: EmailHeaders,
|
||||||
|
pub content: Content,
|
||||||
|
pub parsed: ParsedEmail,
|
||||||
|
pub analysis: Option<Analysis>,
|
||||||
|
pub auth: Option<Auth>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct SmtpEnvelope {
|
||||||
|
pub helo: Option<String>,
|
||||||
|
pub mail_from: String,
|
||||||
|
pub rcpt_to: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct EmailHeaders {
|
||||||
|
pub message_id: Option<String>,
|
||||||
|
pub subject: Option<String>,
|
||||||
|
pub from: String,
|
||||||
|
pub to: String,
|
||||||
|
pub date: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct Content {
|
||||||
|
pub raw: RawContent,
|
||||||
|
pub download: Option<Download>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct Download {
|
||||||
|
pub url: String,
|
||||||
|
pub expires_at: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct RawContent {
|
||||||
|
pub included: bool,
|
||||||
|
pub data: Option<String>,
|
||||||
|
pub sha256: Option<String>,
|
||||||
|
pub encoding: Option<String>,
|
||||||
|
pub size_bytes: Option<u64>,
|
||||||
|
pub max_inline_bytes: Option<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct ParsedEmail {
|
||||||
|
pub status: String,
|
||||||
|
pub body_text: Option<String>,
|
||||||
|
pub body_html: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub attachments: Vec<serde_json::Value>,
|
||||||
|
pub cc: Option<String>,
|
||||||
|
pub bcc: Option<String>,
|
||||||
|
pub error: Option<String>,
|
||||||
|
pub reply_to: Option<String>,
|
||||||
|
pub references: Option<String>,
|
||||||
|
pub in_reply_to: Option<String>,
|
||||||
|
pub attachments_download_url: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct Analysis {
|
||||||
|
pub spamassassin: Option<SpamScore>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct SpamScore {
|
||||||
|
pub score: Option<f64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct Auth {
|
||||||
|
pub spf: Option<String>,
|
||||||
|
pub dmarc: Option<String>,
|
||||||
|
pub dmarc_policy: Option<String>,
|
||||||
|
pub dkim_signatures: Option<Vec<DkimSignature>>,
|
||||||
|
pub dmarc_spf_strict: Option<bool>,
|
||||||
|
pub dmarc_dkim_strict: Option<bool>,
|
||||||
|
pub dmarc_from_domain: Option<String>,
|
||||||
|
pub dmarc_spf_aligned: Option<bool>,
|
||||||
|
pub dmarc_dkim_aligned: Option<bool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct DkimSignature {
|
||||||
|
pub algo: String,
|
||||||
|
pub domain: String,
|
||||||
|
pub result: String,
|
||||||
|
pub aligned: bool,
|
||||||
|
pub key_bits: Option<u32>,
|
||||||
|
pub selector: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse the MyMX-Signature header to extract timestamp and signature.
|
||||||
|
/// Format: `t=<unix_ts>,v1=<hmac_hex>`
|
||||||
|
fn parse_signature_header(header: &str) -> Result<(i64, String), Error> {
|
||||||
|
let mut timestamp = None;
|
||||||
|
let mut signature = None;
|
||||||
|
|
||||||
|
for part in header.split(',') {
|
||||||
|
let part = part.trim();
|
||||||
|
if let Some(t) = part.strip_prefix("t=") {
|
||||||
|
timestamp = Some(
|
||||||
|
t.parse::<i64>()
|
||||||
|
.map_err(|_| Error::InvalidSignatureHeader)?,
|
||||||
|
);
|
||||||
|
} else if let Some(v) = part.strip_prefix("v1=") {
|
||||||
|
signature = Some(v.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
match (timestamp, signature) {
|
||||||
|
(Some(t), Some(s)) => Ok((t, s)),
|
||||||
|
_ => Err(Error::InvalidSignatureHeader),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Verify the webhook signature from MyMX.
|
||||||
|
///
|
||||||
|
/// 1. Parse the signature header to extract timestamp and HMAC
|
||||||
|
/// 2. Construct the signed payload: `"{timestamp}.{raw_body}"`
|
||||||
|
/// 3. Compute HMAC-SHA256 with the webhook secret
|
||||||
|
/// 4. Constant-time compare
|
||||||
|
/// 5. Reject timestamps older than 5 minutes
|
||||||
|
pub fn verify_signature(raw_body: &str, signature_header: &str, secret: &str) -> Result<(), Error> {
|
||||||
|
let (timestamp, expected_sig) = parse_signature_header(signature_header)?;
|
||||||
|
|
||||||
|
// Replay protection: reject timestamps older than 5 minutes
|
||||||
|
let now = Utc::now().timestamp();
|
||||||
|
if now - timestamp > 300 {
|
||||||
|
return Err(Error::TimestampTooOld);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Construct signed payload
|
||||||
|
let payload = format!("{}.{}", timestamp, raw_body);
|
||||||
|
|
||||||
|
// Compute HMAC-SHA256
|
||||||
|
let mut mac =
|
||||||
|
HmacSha256::new_from_slice(secret.as_bytes()).map_err(|e| Error::HmacError(e.to_string()))?;
|
||||||
|
mac.update(payload.as_bytes());
|
||||||
|
let result = hex::encode(mac.finalize().into_bytes());
|
||||||
|
|
||||||
|
// Constant-time compare
|
||||||
|
if result.as_bytes().ct_eq(expected_sig.as_bytes()).into() {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(Error::SignatureVerificationFailed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse the raw webhook body into an `EmailReceivedEvent`.
|
||||||
|
pub fn parse_webhook(raw_body: &str) -> Result<EmailReceivedEvent, Error> {
|
||||||
|
Ok(serde_json::from_str(raw_body)?)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Verify signature and parse the webhook in one step.
|
||||||
|
pub fn handle_webhook(
|
||||||
|
raw_body: &str,
|
||||||
|
signature_header: &str,
|
||||||
|
secret: &str,
|
||||||
|
) -> Result<EmailReceivedEvent, Error> {
|
||||||
|
verify_signature(raw_body, signature_header, secret)?;
|
||||||
|
parse_webhook(raw_body)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_signature_header() {
|
||||||
|
let (t, sig) = parse_signature_header("t=1234567890,v1=abcdef").unwrap();
|
||||||
|
assert_eq!(t, 1234567890);
|
||||||
|
assert_eq!(sig, "abcdef");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_signature_header_invalid() {
|
||||||
|
assert!(parse_signature_header("invalid").is_err());
|
||||||
|
assert!(parse_signature_header("t=abc,v1=def").is_err());
|
||||||
|
assert!(parse_signature_header("t=123").is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_verify_signature() {
|
||||||
|
let secret = "test_secret";
|
||||||
|
let body = r#"{"test": true}"#;
|
||||||
|
let now = Utc::now().timestamp();
|
||||||
|
|
||||||
|
// Compute expected signature
|
||||||
|
let payload = format!("{}.{}", now, body);
|
||||||
|
let mut mac = HmacSha256::new_from_slice(secret.as_bytes()).unwrap();
|
||||||
|
mac.update(payload.as_bytes());
|
||||||
|
let sig = hex::encode(mac.finalize().into_bytes());
|
||||||
|
|
||||||
|
let header = format!("t={},v1={}", now, sig);
|
||||||
|
assert!(verify_signature(body, &header, secret).is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_real_payload() {
|
||||||
|
let payload = r#"{"id":"evt_8fe3f76b51ffd44da96bbb98","email":{"id":"e90fef93-9549-4e17-b86f-295c13089645","auth":{"spf":"fail","dmarc":"pass","dmarcPolicy":"reject","dkimSignatures":[{"algo":"rsa-sha256","domain":"extremist.software","result":"pass","aligned":true,"keyBits":2048,"selector":"202602r"},{"algo":"ed25519-sha256","domain":"extremist.software","result":"pass","aligned":true,"keyBits":null,"selector":"202602e"}],"dmarcSpfStrict":false,"dmarcDkimStrict":false,"dmarcFromDomain":"extremist.software","dmarcSpfAligned":false,"dmarcDkimAligned":true},"smtp":{"helo":"extremist.software","rcpt_to":["mail@mymx.extremist.software"],"mail_from":"jet@extremist.software"},"parsed":{"cc":null,"bcc":null,"error":null,"status":"complete","reply_to":null,"body_html":null,"body_text":"hello","references":null,"attachments":[],"in_reply_to":null,"attachments_download_url":null},"content":{"raw":{"data":"dGVzdA==","sha256":"2c0e0a77","encoding":"base64","included":true,"size_bytes":1189,"max_inline_bytes":262144},"download":{"url":"https://example.com/download","expires_at":"2026-03-05T21:19:50.000Z"}},"headers":{"to":"mail@mymx.extremist.software","date":"Wed, 4 Mar 2026 13:19:42 -0800","from":"Jet <jet@extremist.software>","subject":"hello","message_id":"<2c7d9c88@extremist.software>"},"analysis":{"spamassassin":{"score":2}},"received_at":"2026-03-04T21:19:45.686Z"},"event":"email.received","version":"2025-12-14","delivery":{"attempt":1,"endpoint_id":"eb257e74-566c-4110-a35e-c7e02d00b035","attempted_at":"2026-03-04T21:19:50.626Z"}}"#;
|
||||||
|
let event = parse_webhook(payload).unwrap();
|
||||||
|
assert_eq!(event.email.headers.from, "Jet <jet@extremist.software>");
|
||||||
|
assert_eq!(event.email.parsed.body_text.as_deref(), Some("hello"));
|
||||||
|
assert_eq!(event.email.headers.subject.as_deref(), Some("hello"));
|
||||||
|
assert_eq!(event.email.id, "e90fef93-9549-4e17-b86f-295c13089645");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_verify_signature_replay() {
|
||||||
|
let secret = "test_secret";
|
||||||
|
let body = r#"{"test": true}"#;
|
||||||
|
let old_ts = Utc::now().timestamp() - 600; // 10 minutes ago
|
||||||
|
|
||||||
|
let payload = format!("{}.{}", old_ts, body);
|
||||||
|
let mut mac = HmacSha256::new_from_slice(secret.as_bytes()).unwrap();
|
||||||
|
mac.update(payload.as_bytes());
|
||||||
|
let sig = hex::encode(mac.finalize().into_bytes());
|
||||||
|
|
||||||
|
let header = format!("t={},v1={}", old_ts, sig);
|
||||||
|
assert!(matches!(
|
||||||
|
verify_signature(body, &header, secret),
|
||||||
|
Err(Error::TimestampTooOld)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
17
services/mymx/mymx-server/Cargo.toml
Normal file
17
services/mymx/mymx-server/Cargo.toml
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
[package]
|
||||||
|
name = "mymx-server"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
mymx-sdk = { path = "../mymx-sdk" }
|
||||||
|
axum = "0.8"
|
||||||
|
tokio = { version = "1", features = ["full"] }
|
||||||
|
sqlx = { version = "0.8", features = ["runtime-tokio", "tls-rustls", "postgres", "chrono"] }
|
||||||
|
sha2 = "0.10"
|
||||||
|
hex = "0.4"
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
|
tracing = "0.1"
|
||||||
|
tracing-subscriber = "0.3"
|
||||||
|
chrono = { version = "0.4", features = ["serde"] }
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
CREATE TABLE IF NOT EXISTS emails (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
body TEXT NOT NULL,
|
||||||
|
subject TEXT,
|
||||||
|
received_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
ALTER TABLE emails ADD COLUMN from_address TEXT;
|
||||||
|
ALTER TABLE emails ADD COLUMN mymx_email_id TEXT UNIQUE;
|
||||||
299
services/mymx/mymx-server/src/main.rs
Normal file
299
services/mymx/mymx-server/src/main.rs
Normal file
|
|
@ -0,0 +1,299 @@
|
||||||
|
use axum::{
|
||||||
|
extract::State,
|
||||||
|
http::{HeaderMap, StatusCode},
|
||||||
|
response::{Html, IntoResponse},
|
||||||
|
routing::{get, post},
|
||||||
|
Json, Router,
|
||||||
|
};
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
use sqlx::postgres::PgPoolOptions;
|
||||||
|
use sqlx::{FromRow, PgPool};
|
||||||
|
use std::env;
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
struct AppState {
|
||||||
|
db: PgPool,
|
||||||
|
webhook_secret: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Serialize)]
|
||||||
|
struct WebhookResponse {
|
||||||
|
received: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Serialize)]
|
||||||
|
struct ErrorResponse {
|
||||||
|
error: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Serialize)]
|
||||||
|
struct HealthResponse {
|
||||||
|
status: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
error: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(FromRow)]
|
||||||
|
struct EmailRow {
|
||||||
|
body: String,
|
||||||
|
subject: Option<String>,
|
||||||
|
from_address: Option<String>,
|
||||||
|
received_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn map_webhook_error(e: mymx_sdk::Error) -> (StatusCode, Json<ErrorResponse>) {
|
||||||
|
match &e {
|
||||||
|
mymx_sdk::Error::InvalidSignatureHeader => {
|
||||||
|
tracing::warn!("Webhook rejected: invalid signature header format");
|
||||||
|
(
|
||||||
|
StatusCode::UNAUTHORIZED,
|
||||||
|
Json(ErrorResponse {
|
||||||
|
error: "invalid signature header".into(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
mymx_sdk::Error::SignatureVerificationFailed => {
|
||||||
|
tracing::warn!("Webhook rejected: signature verification failed");
|
||||||
|
(
|
||||||
|
StatusCode::UNAUTHORIZED,
|
||||||
|
Json(ErrorResponse {
|
||||||
|
error: "signature verification failed".into(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
mymx_sdk::Error::TimestampTooOld => {
|
||||||
|
tracing::warn!("Webhook rejected: timestamp too old (replay protection)");
|
||||||
|
(
|
||||||
|
StatusCode::UNAUTHORIZED,
|
||||||
|
Json(ErrorResponse {
|
||||||
|
error: "timestamp too old".into(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
mymx_sdk::Error::HmacError(msg) => {
|
||||||
|
tracing::warn!("Webhook rejected: HMAC error: {}", msg);
|
||||||
|
(
|
||||||
|
StatusCode::UNAUTHORIZED,
|
||||||
|
Json(ErrorResponse {
|
||||||
|
error: "hmac error".into(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
mymx_sdk::Error::ParseError(parse_err) => {
|
||||||
|
tracing::warn!("Webhook body parse failed: {}", parse_err);
|
||||||
|
(
|
||||||
|
StatusCode::UNPROCESSABLE_ENTITY,
|
||||||
|
Json(ErrorResponse {
|
||||||
|
error: format!("failed to parse webhook body: {}", parse_err),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() {
|
||||||
|
tracing_subscriber::fmt::init();
|
||||||
|
|
||||||
|
let database_url = env::var("DATABASE_URL").expect("DATABASE_URL must be set");
|
||||||
|
let webhook_secret =
|
||||||
|
env::var("MYMX_WEBHOOK_SECRET").expect("MYMX_WEBHOOK_SECRET must be set");
|
||||||
|
let listen_addr = env::var("LISTEN_ADDR").unwrap_or_else(|_| "127.0.0.1:4002".to_string());
|
||||||
|
|
||||||
|
let pool = PgPoolOptions::new()
|
||||||
|
.max_connections(5)
|
||||||
|
.connect(&database_url)
|
||||||
|
.await
|
||||||
|
.expect("Failed to connect to database");
|
||||||
|
|
||||||
|
sqlx::migrate!()
|
||||||
|
.run(&pool)
|
||||||
|
.await
|
||||||
|
.expect("Failed to run migrations");
|
||||||
|
|
||||||
|
let state = AppState {
|
||||||
|
db: pool,
|
||||||
|
webhook_secret,
|
||||||
|
};
|
||||||
|
|
||||||
|
let app = Router::new()
|
||||||
|
.route("/webhook", post(webhook_handler))
|
||||||
|
.route("/health", get(health_handler))
|
||||||
|
.route("/", get(index_handler))
|
||||||
|
.with_state(state);
|
||||||
|
|
||||||
|
let listener = tokio::net::TcpListener::bind(&listen_addr)
|
||||||
|
.await
|
||||||
|
.expect("Failed to bind listener");
|
||||||
|
|
||||||
|
tracing::info!("Listening on {}", listen_addr);
|
||||||
|
axum::serve(listener, app).await.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn webhook_handler(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
body: String,
|
||||||
|
) -> Result<Json<WebhookResponse>, (StatusCode, Json<ErrorResponse>)> {
|
||||||
|
let signature = headers
|
||||||
|
.get("MyMX-Signature")
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
.ok_or((
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
Json(ErrorResponse {
|
||||||
|
error: "missing MyMX-Signature header".into(),
|
||||||
|
}),
|
||||||
|
))?;
|
||||||
|
|
||||||
|
mymx_sdk::verify_signature(&body, signature, &state.webhook_secret)
|
||||||
|
.map_err(map_webhook_error)?;
|
||||||
|
|
||||||
|
let event = mymx_sdk::parse_webhook(&body).map_err(map_webhook_error)?;
|
||||||
|
|
||||||
|
let email_body = event
|
||||||
|
.email
|
||||||
|
.parsed
|
||||||
|
.body_text
|
||||||
|
.or(event.email.parsed.body_html)
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
let subject = event.email.headers.subject;
|
||||||
|
let from_address = event.email.headers.from;
|
||||||
|
let mymx_email_id = event.email.id;
|
||||||
|
let received_at = chrono::DateTime::parse_from_rfc3339(&event.email.received_at)
|
||||||
|
.map(|dt| dt.with_timezone(&Utc))
|
||||||
|
.unwrap_or_else(|_| Utc::now());
|
||||||
|
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO emails (body, subject, from_address, mymx_email_id, received_at)
|
||||||
|
VALUES ($1, $2, $3, $4, $5)
|
||||||
|
ON CONFLICT (mymx_email_id) DO NOTHING",
|
||||||
|
)
|
||||||
|
.bind(&email_body)
|
||||||
|
.bind(&subject)
|
||||||
|
.bind(&from_address)
|
||||||
|
.bind(&mymx_email_id)
|
||||||
|
.bind(received_at)
|
||||||
|
.execute(&state.db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
tracing::error!("Database insert failed: {}", e);
|
||||||
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
Json(ErrorResponse {
|
||||||
|
error: "internal server error".into(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
tracing::info!(
|
||||||
|
subject = subject.as_deref().unwrap_or("(none)"),
|
||||||
|
from = %from_address,
|
||||||
|
email_id = %mymx_email_id,
|
||||||
|
"Received email"
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(Json(WebhookResponse { received: true }))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn health_handler(State(state): State<AppState>) -> impl IntoResponse {
|
||||||
|
match sqlx::query_scalar::<_, i32>("SELECT 1").fetch_one(&state.db).await {
|
||||||
|
Ok(_) => (
|
||||||
|
StatusCode::OK,
|
||||||
|
Json(HealthResponse {
|
||||||
|
status: "ok".into(),
|
||||||
|
error: None,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
Err(e) => (
|
||||||
|
StatusCode::SERVICE_UNAVAILABLE,
|
||||||
|
Json(HealthResponse {
|
||||||
|
status: "unhealthy".into(),
|
||||||
|
error: Some(e.to_string()),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn index_handler(State(state): State<AppState>) -> Result<Html<String>, StatusCode> {
|
||||||
|
let emails: Vec<EmailRow> = sqlx::query_as::<_, EmailRow>(
|
||||||
|
"SELECT body, subject, from_address, received_at FROM emails",
|
||||||
|
)
|
||||||
|
.fetch_all(&state.db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
tracing::error!("Database query failed: {}", e);
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let mut rows: Vec<(String, String, String, DateTime<Utc>)> = emails
|
||||||
|
.into_iter()
|
||||||
|
.map(|email| {
|
||||||
|
let mut hasher = Sha256::new();
|
||||||
|
hasher.update(email.body.as_bytes());
|
||||||
|
let hash = hex::encode(hasher.finalize());
|
||||||
|
let subject = email.subject.unwrap_or_default();
|
||||||
|
let from = email.from_address.unwrap_or_default();
|
||||||
|
(hash, subject, from, email.received_at)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// Sort alphabetically by hash
|
||||||
|
rows.sort_by(|a, b| a.0.cmp(&b.0));
|
||||||
|
|
||||||
|
let table_rows: String = rows
|
||||||
|
.iter()
|
||||||
|
.map(|(hash, subject, from, received_at)| {
|
||||||
|
format!(
|
||||||
|
"<tr><td><code>{}</code></td><td>{}</td><td>{}</td><td>{}</td></tr>",
|
||||||
|
hash,
|
||||||
|
html_escape(from),
|
||||||
|
html_escape(subject),
|
||||||
|
received_at.format("%Y-%m-%d %H:%M:%S UTC")
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("\n");
|
||||||
|
|
||||||
|
let html = format!(
|
||||||
|
r#"<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>MyMX Emails</title>
|
||||||
|
<style>
|
||||||
|
body {{ font-family: monospace; margin: 2rem; background: #1a1a1a; color: #e0e0e0; }}
|
||||||
|
h1 {{ color: #ffffff; }}
|
||||||
|
table {{ border-collapse: collapse; width: 100%; }}
|
||||||
|
th, td {{ border: 1px solid #333; padding: 0.5rem; text-align: left; }}
|
||||||
|
th {{ background: #2a2a2a; }}
|
||||||
|
tr:nth-child(even) {{ background: #222; }}
|
||||||
|
code {{ font-size: 0.85em; word-break: break-all; }}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>MyMX Emails</h1>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr><th>SHA-256 Hash</th><th>From</th><th>Subject</th><th>Received</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</body>
|
||||||
|
</html>"#,
|
||||||
|
table_rows
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(Html(html))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn html_escape(s: &str) -> String {
|
||||||
|
s.replace('&', "&")
|
||||||
|
.replace('<', "<")
|
||||||
|
.replace('>', ">")
|
||||||
|
.replace('"', """)
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue