Skip to content

Commit 3c45690

Browse files
committed
Implemented basic minimal HTTP POST web hook for new mails
1 parent 24bb0fb commit 3c45690

File tree

4 files changed

+104
-4
lines changed

4 files changed

+104
-4
lines changed

src/background.rs

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ use crate::state::{
55
AppState, DmarcReportWithMailId, FileType, ReportParsingError, TlsReportWithMailId,
66
};
77
use crate::unpack::extract_report_files;
8+
use crate::web_hook::mail_web_hook;
89
use crate::{dmarc, tls};
910
use anyhow::{Context, Result};
1011
use chrono::Local;
@@ -14,7 +15,7 @@ use std::time::{Duration, Instant, SystemTime};
1415
use tokio::sync::Mutex;
1516
use tokio::sync::mpsc::Receiver;
1617
use tokio::task::JoinHandle;
17-
use tracing::{error, info, trace, warn};
18+
use tracing::{debug, error, info, trace, warn};
1819

1920
pub fn start_bg_task(
2021
config: Configuration,
@@ -31,13 +32,20 @@ pub fn start_bg_task(
3132
info!("Starting background update...");
3233
match bg_update(&config, &state).await {
3334
Ok(new_mails) => {
34-
if !new_mails.is_empty() {
35-
info!("Detected {} new mails", new_mails.len());
36-
}
35+
info!("Detected {} new mails", new_mails.len());
3736
info!(
3837
"Finished background update after {:.3}s",
3938
start.elapsed().as_secs_f64()
4039
);
40+
if !new_mails.is_empty() && config.mail_web_hook_url.is_some() {
41+
debug!("Calling web hook for new mails...");
42+
for mail_id in &new_mails {
43+
if let Err(err) = mail_web_hook(&config, mail_id).await {
44+
warn!("Failed to call web hook for mail {mail_id}: {err:#}");
45+
}
46+
}
47+
debug!("Finished calling web hook for new mails");
48+
}
4149
}
4250
Err(err) => error!("Failed background update: {err:#}"),
4351
};

src/config.rs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,15 @@ pub struct Configuration {
138138
/// Maximum mail size in bytes, anything bigger will be ignored and not parsed
139139
#[arg(long, env, default_value_t = 1024 * 1024 * 1)]
140140
pub max_mail_size: u32,
141+
142+
/// URL for optional web hook that is called via HTTP when a new mail is detected.
143+
/// Please note that this app does not have a persistent store for already known mails.
144+
/// When the application starts, all existing mails in the IMAP account are considered known.
145+
/// Only the subsequent updates that occur while the app is running will be able to detect new mails.
146+
/// The URL only supports plain HTTP and will reveive a HTTP POST request.
147+
/// Example value: http://myserver.org/api/new_mails_endpoint
148+
#[arg(long, env)]
149+
pub mail_web_hook_url: Option<String>,
141150
}
142151

143152
impl Configuration {
@@ -179,6 +188,8 @@ impl Configuration {
179188
info!("HTTPS Cache Dir: {:?}", self.https_auto_cert_cache);
180189

181190
info!("Maximum Mail Body Size: {} bytes", self.max_mail_size);
191+
192+
info!("Mail Web Hook URL: {:?}", self.mail_web_hook_url);
182193
}
183194
}
184195

src/main.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ mod mail;
1212
mod state;
1313
mod tls;
1414
mod unpack;
15+
mod web_hook;
1516
mod whois;
1617

1718
use crate::background::start_bg_task;

src/web_hook.rs

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
use crate::config::Configuration;
2+
use anyhow::{Context, Result, bail, ensure};
3+
use axum::http::uri::Scheme;
4+
use http_body_util::{BodyExt, Empty};
5+
use hyper::body::Bytes;
6+
use hyper::client::conn::http1;
7+
use hyper::{Method, Request, Uri};
8+
use hyper_util::rt::TokioIo;
9+
use tokio::net::TcpStream;
10+
use tracing::{debug, error};
11+
12+
pub async fn mail_web_hook(config: &Configuration, mail_id: &str) -> Result<()> {
13+
let url = config
14+
.mail_web_hook_url
15+
.as_deref()
16+
.context("Failed to get web hook URL for new mails")?;
17+
18+
// Create and parse URI
19+
let uri = url.parse::<Uri>().context("Failed to parse URL")?;
20+
ensure!(
21+
uri.scheme().context("URL has no scheme")? == &Scheme::HTTP,
22+
"Only plain HTTP is supported"
23+
);
24+
25+
// Get the host and the port
26+
let host = uri.host().context("URL has no host")?.to_string();
27+
let port = uri.port_u16().unwrap_or(80);
28+
29+
// Open a TCP connection to the remote host
30+
let address = format!("{host}:{port}");
31+
let stream = TcpStream::connect(&address)
32+
.await
33+
.context(format!("Failed to connect TCP stream at {address}"))?;
34+
35+
// Create the Hyper client
36+
let io = TokioIo::new(stream);
37+
let (mut sender, conn) = http1::handshake(io)
38+
.await
39+
.context("Failed to create HTTP handshake")?;
40+
41+
// Spawn a task to drive the HTTP state
42+
tokio::task::spawn(async move {
43+
if let Err(err) = conn.await {
44+
error!("Connection failed: {err:?}");
45+
}
46+
});
47+
48+
// Create and send HTTP request
49+
let req = Request::builder()
50+
.uri(uri)
51+
.method(Method::POST)
52+
.header(hyper::header::HOST, host)
53+
.body(Empty::<Bytes>::new())
54+
.context("Failed to create HTTP request")?;
55+
let mut res = sender
56+
.send_request(req)
57+
.await
58+
.context("Failed to send HTTP request")?;
59+
60+
let status_code = res.status().as_u16();
61+
debug!("Web hook for new mail {mail_id} responded with status code {status_code}");
62+
63+
// Get response body piece by piece
64+
let mut body = Vec::new();
65+
while let Some(next) = res.frame().await {
66+
let frame = next.context("Failed to receive next HTTP response chunk")?;
67+
if let Some(chunk) = frame.data_ref() {
68+
body.extend_from_slice(chunk);
69+
}
70+
if body.len() > 1024 * 1024 {
71+
bail!("HTTP response too big");
72+
}
73+
}
74+
75+
// Parse and log response body
76+
let body = String::from_utf8_lossy(&body);
77+
debug!("Web hook for new mail {mail_id} responded with body: {body}");
78+
79+
Ok(())
80+
}

0 commit comments

Comments
 (0)