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