feat: create mymx service
This commit is contained in:
parent
478af69792
commit
ad8cb52169
20 changed files with 3152 additions and 1 deletions
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)
|
||||
));
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue