rhombus 0.2.21

Next generation extendable CTF framework with batteries included
Documentation
use std::sync::Arc;

use async_trait::async_trait;
use axum::{
    body::{Body, Bytes},
    extract::{FromRequest, Multipart, State},
    http::{HeaderMap, Request, Response},
    response::IntoResponse,
    routing::post,
    Form, Router,
};
use ring::hmac;
use serde::Deserialize;
use serde_json::json;
use tokio::sync::RwLock;

use crate::{
    errors::Result,
    internal::{
        discord::DiscordAttachment,
        email::provider::{InboundEmail, OutboundEmailProvider},
        local_upload_provider::slice_to_hex_string,
        router::RouterState,
        settings::Settings,
    },
};

pub struct MailgunProvider {
    pub settings: Arc<RwLock<Settings>>,
}

pub fn mailgun_error(error: &str) -> Response<Body> {
    Response::builder()
        .status(406)
        .header("Content-Type", "application/json")
        .body(Body::from(json!({ "error": error }).to_string()))
        .unwrap()
}

pub async fn route_mailgun_receive_email(
    state: State<RouterState>,
    headers: HeaderMap,
    req: Request<Body>,
) -> impl IntoResponse {
    tracing::info!("recieving mailgun");

    let Some(ref bot) = state.bot else {
        return mailgun_error("Discord bot not configured");
    };

    let Some(content_type) = headers.get("content-type") else {
        return mailgun_error("Content-Type not found");
    };

    let (main_message, in_reply_to, from, attachments, timestamp, token, signature) =
        match content_type.as_bytes() {
            b"application/x-www-form-urlencoded" => {
                let Ok(form) = Form::<MailgunForm>::from_request(req, &()).await else {
                    return mailgun_error("Failed to parse form");
                };

                (
                    form.stripped_text.clone(),
                    form.in_reply_to.clone(),
                    form.from.clone(),
                    vec![],
                    form.timestamp,
                    form.token.clone(),
                    form.signature.clone(),
                )
            }
            b if b.starts_with(b"multipart/form-data") => {
                let mut multipart = Multipart::from_request(req, &()).await.unwrap();

                struct Attachment {
                    filename: String,
                    data: Bytes,
                }

                let mut attachments = vec![];
                let mut in_reply_to = None;
                let mut main_message = None;
                let mut from = None;
                let mut timestamp = None;
                let mut token = None;
                let mut signature = None;
                while let Some(field) = multipart.next_field().await.unwrap() {
                    if let Some(file_name) = field.file_name() {
                        attachments.push(Attachment {
                            filename: file_name.to_owned(),
                            data: field.bytes().await.unwrap(),
                        })
                    } else if let Some(name) = field.name() {
                        match name {
                            "In-Reply-To" => in_reply_to = Some(field.text().await.unwrap()),
                            "stripped-text" => main_message = Some(field.text().await.unwrap()),
                            "From" => from = Some(field.text().await.unwrap()),
                            "timestamp" => timestamp = Some(field.text().await.unwrap()),
                            "token" => token = Some(field.text().await.unwrap()),
                            "signature" => signature = Some(field.text().await.unwrap()),
                            _ => (),
                        }
                    }
                }

                let Some(in_reply_to) = in_reply_to else {
                    return mailgun_error("In-Reply-To not found");
                };

                let Some(main_message) = main_message else {
                    return mailgun_error("stripped-text not found");
                };

                let Some(timestamp) = timestamp else {
                    return mailgun_error("timestamp not found");
                };

                let Ok(timestamp) = timestamp.parse::<u64>() else {
                    return mailgun_error("Invalid timestamp");
                };

                let Some(token) = token else {
                    return mailgun_error("token not found");
                };

                let Some(signature) = signature else {
                    return mailgun_error("signature not found");
                };

                (
                    main_message,
                    in_reply_to,
                    from,
                    attachments,
                    timestamp,
                    token,
                    signature,
                )
            }
            _ => {
                return mailgun_error("Invalid Content-Type");
            }
        };

    let webhook_signing_key = {
        let settings = state.settings.read().await;
        settings
            .email
            .as_ref()
            .unwrap()
            .mailgun
            .as_ref()
            .unwrap()
            .webhook_signing_key
            .clone()
    };

    let tag = hmac::sign(
        &hmac::Key::new(hmac::HMAC_SHA256, webhook_signing_key.as_bytes()),
        format!("{}{}", timestamp, token).as_bytes(),
    );
    let tag_signature = slice_to_hex_string(tag.as_ref());

    if tag_signature != signature {
        return mailgun_error("Invalid signature");
    }

    let Ok(Some(ticket_number)) = state.db.get_ticket_number_by_message_id(&in_reply_to).await
    else {
        tracing::error!(in_reply_to, "Failed to find ticket number");
        return mailgun_error("Internal error");
    };

    let Ok(ticket) = state.db.get_ticket_by_ticket_number(ticket_number).await else {
        tracing::error!(ticket_number, "Failed to find ticket");
        return mailgun_error("Internal error");
    };

    let Ok(user) = state.db.get_user_from_id(ticket.user_id).await else {
        tracing::error!(ticket.user_id, "Failed to find user");
        return mailgun_error("Internal error");
    };

    if bot
        .send_external_ticket_message(
            ticket.discord_channel_id,
            &user,
            from.as_deref(),
            &main_message,
            &attachments
                .iter()
                .map(|a| DiscordAttachment {
                    data: &a.data,
                    filename: &a.filename,
                })
                .collect::<Vec<_>>(),
        )
        .await
        .is_err()
    {
        tracing::error!("Failed to send external ticket message");
    }

    Response::builder()
        .header("Content-Type", "application/json")
        .body(Body::from(json!({ "status": "ok" }).to_string()))
        .unwrap()
}

#[derive(Debug, Deserialize)]
pub struct MailgunForm {
    #[serde(rename = "stripped-text")]
    stripped_text: String,
    #[serde(rename = "In-Reply-To")]
    in_reply_to: String,
    #[serde(rename = "From")]
    from: Option<String>,

    timestamp: u64,
    token: String,
    signature: String,
}

impl MailgunProvider {
    pub async fn new(settings: Arc<RwLock<Settings>>) -> Result<(Self, Router<RouterState>)> {
        _ = settings
            .read()
            .await
            .email
            .as_ref()
            .ok_or(crate::errors::RhombusError::MissingConfiguration(
                "email".to_owned(),
            ))?
            .mailgun
            .as_ref()
            .ok_or(crate::errors::RhombusError::MissingConfiguration(
                "mailgun".to_owned(),
            ))?;

        let router = Router::new().route("/mailgun", post(route_mailgun_receive_email));
        Ok((Self { settings }, router))
    }
}

impl InboundEmail for MailgunProvider {
    async fn receive_emails(&self) -> Result<()> {
        Ok(())
    }
}

#[async_trait]
impl OutboundEmailProvider for MailgunProvider {
    async fn send_email(
        &self,
        to: &str,
        subject: &str,
        plaintext: &str,
        html: &str,
        in_reply_to: Option<&str>,
        references: &[String],
    ) -> Result<String> {
        let (mailgun_settings, from) = {
            let settings = self.settings.read().await;
            let email_settings = settings.email.as_ref().unwrap();
            (
                email_settings.mailgun.as_ref().unwrap().clone(),
                email_settings.from.clone(),
            )
        };

        let mailgun_endpoint = mailgun_settings
            .endpoint
            .as_deref()
            .unwrap_or("https://api.mailgun.net/v3");

        let response = reqwest::Client::new()
            .post(format!(
                "{}/{}/messages",
                mailgun_endpoint, mailgun_settings.domain
            ))
            .basic_auth("api", Some(mailgun_settings.api_key))
            .form(&[
                ("from", Some(from)),
                ("to", Some(to.to_owned())),
                ("subject", Some(subject.to_owned())),
                ("text", Some(plaintext.to_owned())),
                ("html", Some(html.to_owned())),
                ("h:in-reply-to", in_reply_to.map(|s| s.to_owned())),
                (
                    "h:references",
                    (!references.is_empty()).then(|| references.join(" ")),
                ),
            ])
            .send()
            .await?;

        #[derive(Deserialize)]
        struct Response {
            id: String,
        }
        let id = response.json::<Response>().await.map(|r| r.id)?;

        Ok(id)
    }
}