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}