Skip to main content

mail_laser/webhook/
mod.rs

1//! Handles sending processed email data to a configured webhook URL via HTTPS POST.
2//!
3//! This module defines the data structure for the webhook payload (`EmailPayload`)
4//! and provides a `WebhookClient` responsible for making the asynchronous HTTP request.
5//! It uses `hyper` and `hyper-rustls` for the underlying HTTP/S communication.
6
7use std::collections::HashMap;
8use anyhow::Result;
9use hyper::Request;
10use hyper_rustls::HttpsConnectorBuilder;
11// Import necessary components from hyper-util, using aliases for clarity.
12use hyper_util::{client::legacy::{connect::HttpConnector, Client}, rt::TokioExecutor};
13use http_body_util::Full; // For creating simple, complete request bodies.
14use bytes::Bytes; // Bytes type for request body data.
15use log::{info, error};
16use serde::{Serialize, Deserialize};
17use crate::config::Config;
18
19// --- Type Aliases for Hyper Client ---
20
21/// Type alias for the HTTPS connector using `hyper-rustls`.
22type HttpsConn = hyper_rustls::HttpsConnector<HttpConnector>;
23/// Type alias for the specific Hyper client configuration used for sending webhooks.
24/// Uses the `HttpsConn` for TLS and expects/sends `Full<Bytes>` bodies.
25type WebhookHttpClient = Client<HttpsConn, Full<Bytes>>;
26
27// --- Public Data Structures ---
28
29/// Represents the data payload sent to the webhook URL.
30///
31/// Contains the essential extracted information from a received email.
32#[derive(Debug, Clone, Serialize, Deserialize)]
33pub struct EmailPayload {
34    /// The email address of the original sender (from MAIL FROM).
35    pub sender: String,
36    /// The display name extracted from the 'Reply-To' header, if available.
37    #[serde(skip_serializing_if = "Option::is_none")]
38    pub sender_name: Option<String>,
39    /// The specific recipient address this email was accepted for (from RCPT TO).
40    pub recipient: String,
41    /// The subject line of the email.
42    pub subject: String,
43    /// The plain text representation of the body (HTML stripped or converted).
44    pub body: String,
45    /// The original HTML body content, if the email contained HTML.
46    #[serde(skip_serializing_if = "Option::is_none")] // Don't include in JSON if None
47    pub html_body: Option<String>,
48    /// Email headers matching configured prefix filters, if any were matched.
49    #[serde(skip_serializing_if = "Option::is_none")]
50    pub headers: Option<HashMap<String, String>>,
51}
52
53/// A client responsible for sending `EmailPayload` data to a configured webhook URL.
54///
55/// Encapsulates the `hyper` HTTP client setup with `rustls` for HTTPS support.
56pub struct WebhookClient {
57    /// Shared application configuration.
58    config: Config,
59    /// The underlying asynchronous HTTP client instance.
60    client: WebhookHttpClient,
61    /// The User-Agent string sent with webhook requests, derived from the crate's metadata.
62    user_agent: String,
63}
64
65impl WebhookClient {
66    /// Creates a new `WebhookClient`.
67    ///
68    /// Initializes an HTTPS client using `hyper-rustls` with native system certificates.
69    /// Constructs a User-Agent string based on the crate's name and version from `Cargo.toml`.
70    ///
71    /// # Arguments
72    ///
73    /// * `config` - The application configuration, used to get the webhook URL.
74    ///
75    /// # Panics
76    ///
77    /// Panics if loading the system's native root TLS certificates fails. This is considered
78    /// a fatal error during startup.
79    pub fn new(config: Config) -> Self {
80        // Configure the HTTPS connector using rustls and native certs.
81        let https = HttpsConnectorBuilder::new()
82            .with_native_roots()
83            // Panic if cert loading fails - essential for HTTPS operation.
84            .expect("Failed to load native root certificates for hyper-rustls")
85            .https_only() // Ensure only HTTPS connections are made.
86            .enable_http1() // Enable HTTP/1.1 support.
87            .build();
88
89        // Build the hyper client using the HTTPS connector and Tokio runtime.
90        let client: WebhookHttpClient = Client::builder(TokioExecutor::new()).build(https);
91
92        // Create a User-Agent string like "MailLaser/0.1.0".
93        let user_agent = format!(
94            "{}/{}",
95            env!("CARGO_PKG_NAME"),
96            env!("CARGO_PKG_VERSION")
97        );
98
99        Self {
100            config,
101            client,
102            user_agent,
103        }
104    }
105
106    /// Sends the given `EmailPayload` to the configured webhook URL.
107    ///
108    /// Serializes the payload to JSON and sends it as an HTTPS POST request.
109    /// Logs the outcome (success or failure status code) of the request.
110    ///
111    /// **Note:** A non-successful HTTP status code from the webhook endpoint (e.g., 4xx, 5xx)
112    /// is logged as an error but does *not* cause this function to return an `Err`.
113    /// The email is considered successfully processed by MailLaser once the webhook
114    /// request is attempted.
115    ///
116    /// # Arguments
117    ///
118    /// * `email` - The `EmailPayload` to send.
119    ///
120    /// # Errors
121    ///
122    /// Returns an `Err` if:
123    /// - Serialization of the `EmailPayload` to JSON fails.
124    /// - Building the HTTP request fails.
125    /// - The HTTP request itself fails (e.g., network error, DNS resolution failure).
126    pub async fn forward_email(&self, email: EmailPayload) -> Result<()> {
127        // Log clearly showing sender email and name (if available)
128        info!(
129            "Forwarding email from sender '{}' (Name: {}) with subject: '{}'",
130            email.sender,
131            email.sender_name.as_deref().unwrap_or("N/A"), // Provide "N/A" if name is None
132            email.subject
133        );
134
135        // Serialize payload to JSON. This can fail if the payload is invalid (unlikely here).
136        let json_body = serde_json::to_string(&email)?;
137
138        // Build the POST request.
139        let request = Request::builder()
140            .method(hyper::Method::POST)
141            .uri(&self.config.webhook_url) // Target URL from config.
142            .header("content-type", "application/json") // Set JSON content type.
143            .header("user-agent", &self.user_agent) // Set the custom User-Agent.
144            // Create the request body from the serialized JSON string.
145            .body(Full::new(Bytes::from(json_body)))?; // This can fail if headers/URI are invalid.
146
147        // Send the request asynchronously using the hyper client.
148        let response = self.client.request(request).await?;
149
150        // Check the HTTP status code of the response.
151        let status = response.status();
152        if !status.is_success() {
153            // Log webhook failures but don't propagate the error, as per design.
154            error!(
155                "Webhook request to {} failed with status: {}",
156                self.config.webhook_url, status
157            );
158        } else {
159            info!(
160                "Email successfully forwarded to webhook {}, status: {}",
161                self.config.webhook_url, status
162            );
163        }
164
165        // Return Ok regardless of the webhook's response status code.
166        Ok(())
167    }
168}