Single-binary self-hosted uptime monitoring and status pages on Rust axum: HTTP probes, Lighthouse audits, SEO crawler, and PDF reports.
axumdockerrustself-hostedsqlitestatus-pageuptime-monitoringvite
1use anyhow::Context;
2use hickory_resolver::TokioAsyncResolver;
3use lettre::message::header::ContentType;
4use lettre::transport::smtp::client::TlsParameters;
5use lettre::{AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor};
6use minijinja::{AutoEscape, Environment};
7use serde_json::json;
8use uuid::Uuid;
9
10const FROM_ADDR: &str = "[email protected]";
11const HELO: &str = "bythewood.me";
12const SMTP_TIMEOUT_SECS: u64 = 30;
13const EMAIL_BASE_TEMPLATE: &str =
14 include_str!("../templates/emails/property_email_base.html");
15
16/// Property snapshot used to fill in the email metadata table. Computed at the
17/// moment the alert fires so the values match what triggered it.
18pub struct EmailContext {
19 pub id: Uuid,
20 pub name: String,
21 pub url: String,
22 pub current_status: i64,
23 pub avg_response_time: i64,
24}
25
26struct Theme {
27 email_title: &'static str,
28 preheader: String,
29 status_label: &'static str,
30 event_title: &'static str,
31 event_copy: &'static str,
32 accent: &'static str,
33 accent_bright: &'static str,
34 accent_tint: &'static str,
35 accent_border: &'static str,
36}
37
38fn theme_for(kind: &str, name: &str) -> Option<Theme> {
39 match kind {
40 "down" => Some(Theme {
41 email_title: "Property down",
42 preheader: format!("{name} is not responding"),
43 status_label: "Down",
44 event_title: "Your property is down.",
45 event_copy: "We've observed two consecutive failed checks in a row. \
46 Monitoring will continue in the background and you'll get \
47 another note the moment it recovers.",
48 accent: "#c47055",
49 accent_bright: "#e38871",
50 accent_tint: "#201712",
51 accent_border: "#36231b",
52 }),
53 "recovery" => Some(Theme {
54 email_title: "Property recovered",
55 preheader: format!("{name} is back online"),
56 status_label: "Recovered",
57 event_title: "Your property is back online.",
58 event_copy: "The latest check returned a healthy response. Everything \
59 looks normal again; we'll keep monitoring and alert you if \
60 anything changes.",
61 accent: "#6b9e78",
62 accent_bright: "#7db88c",
63 accent_tint: "#191e17",
64 accent_border: "#222d22",
65 }),
66 _ => None,
67 }
68}
69
70pub(crate) fn render_preview_html(kind: &str, base_url: &str) -> anyhow::Result<String> {
71 let theme = theme_for(kind, "example.com")
72 .ok_or_else(|| anyhow::anyhow!("unknown kind: {kind} (use 'down' or 'recovery')"))?;
73 let ctx = EmailContext {
74 id: Uuid::nil(),
75 name: "example.com".to_string(),
76 url: "https://example.com".to_string(),
77 current_status: if kind == "down" { 503 } else { 200 },
78 avg_response_time: 184,
79 };
80 render_email_html(&theme, &ctx, base_url)
81}
82
83fn render_email_html(theme: &Theme, ctx: &EmailContext, base_url: &str) -> anyhow::Result<String> {
84 let mut env = Environment::new();
85 env.set_auto_escape_callback(|_| AutoEscape::Html);
86 env.add_template("email_base.html", EMAIL_BASE_TEMPLATE)
87 .context("compiling email template")?;
88 let tmpl = env.get_template("email_base.html")?;
89 let html = tmpl
90 .render(minijinja::context! {
91 email_title => theme.email_title,
92 preheader => &theme.preheader,
93 status_label => theme.status_label,
94 event_title => theme.event_title,
95 event_copy => theme.event_copy,
96 accent => theme.accent,
97 accent_bright => theme.accent_bright,
98 accent_tint => theme.accent_tint,
99 accent_border => theme.accent_border,
100 BASE_URL => base_url.trim_end_matches('/'),
101 property => minijinja::context! {
102 id => ctx.id.to_string(),
103 name => &ctx.name,
104 url => &ctx.url,
105 current_status => ctx.current_status,
106 avg_response_time => ctx.avg_response_time,
107 },
108 })
109 .context("rendering email template")?;
110 Ok(html)
111}
112
113/// Fire a state-transition notification. `kind` is "down" or "recovery".
114pub async fn fire(
115 kind: &str,
116 ctx: &EmailContext,
117 base_url: &str,
118 alert_email: Option<&str>,
119 discord_webhook: Option<&str>,
120) -> anyhow::Result<()> {
121 let theme = theme_for(kind, &ctx.name).ok_or_else(|| anyhow::anyhow!("unknown alert kind: {kind}"))?;
122 let subject = match kind {
123 "down" => format!("Status: {} is down!", ctx.name),
124 "recovery" => format!("Status: {} is back up!", ctx.name),
125 _ => unreachable!(),
126 };
127 let html = render_email_html(&theme, ctx, base_url)?;
128
129 let mut errors: Vec<anyhow::Error> = Vec::new();
130
131 if let Some(to) = alert_email {
132 if let Err(e) = send_email_via_mx(&subject, &html, to).await {
133 errors.push(e.context("email"));
134 }
135 }
136 if let Some(webhook) = discord_webhook {
137 if let Err(e) = send_discord(kind, &ctx.url, webhook).await {
138 errors.push(e.context("discord"));
139 }
140 }
141 if !errors.is_empty() {
142 let combined = errors.iter().map(|e| format!("{e:#}")).collect::<Vec<_>>().join("; ");
143 anyhow::bail!("{combined}");
144 }
145 Ok(())
146}
147
148/// Direct-MX delivery: resolve the recipient domain's MX records, sort by
149/// preference, try each with STARTTLS. No relay configured by default.
150async fn send_email_via_mx(subject: &str, html: &str, to: &str) -> anyhow::Result<()> {
151 let domain = to
152 .rsplit_once('@')
153 .map(|(_, d)| d.to_string())
154 .ok_or_else(|| anyhow::anyhow!("invalid recipient: {to}"))?;
155
156 let resolver = TokioAsyncResolver::tokio_from_system_conf()
157 .context("creating dns resolver")?;
158 let mx = resolver
159 .mx_lookup(domain.as_str())
160 .await
161 .context("mx lookup")?;
162 let mut records: Vec<_> = mx.iter().collect();
163 records.sort_by_key(|r| r.preference());
164
165 if records.is_empty() {
166 anyhow::bail!("no MX records for {domain}");
167 }
168
169 let email = Message::builder()
170 .from(FROM_ADDR.parse()?)
171 .to(to.parse()?)
172 .subject(subject)
173 .header(ContentType::TEXT_HTML)
174 .body(html.to_string())?;
175
176 let mut last_err: Option<anyhow::Error> = None;
177 for rec in records {
178 let host = rec.exchange().to_utf8();
179 let host = host.trim_end_matches('.').to_string();
180 match try_one_mx(&host, &email).await {
181 Ok(()) => return Ok(()),
182 Err(e) => {
183 tracing::warn!("MX {host} failed for {domain}: {e:#}");
184 last_err = Some(e);
185 }
186 }
187 }
188 Err(last_err.unwrap_or_else(|| anyhow::anyhow!("all MX hosts failed for {domain}")))
189}
190
191async fn try_one_mx(host: &str, email: &Message) -> anyhow::Result<()> {
192 let tls = TlsParameters::builder(host.to_string())
193 // Receiving MTAs sometimes use self-signed/expired certs; opportunistic
194 // STARTTLS with relaxed validation matches what the Python smtplib
195 // version did. End-to-end privacy is the recipient's MTA's job.
196 .dangerous_accept_invalid_certs(true)
197 .dangerous_accept_invalid_hostnames(true)
198 .build_rustls()?;
199
200 let transport: AsyncSmtpTransport<Tokio1Executor> =
201 AsyncSmtpTransport::<Tokio1Executor>::builder_dangerous(host)
202 .port(25)
203 .hello_name(lettre::transport::smtp::extension::ClientId::Domain(HELO.into()))
204 .timeout(Some(std::time::Duration::from_secs(SMTP_TIMEOUT_SECS)))
205 .tls(lettre::transport::smtp::client::Tls::Opportunistic(tls))
206 .build();
207
208 transport.send(email.clone()).await.context("smtp send")?;
209 Ok(())
210}
211
212async fn send_discord(kind: &str, url: &str, webhook: &str) -> anyhow::Result<()> {
213 let (title, color, desc) = match kind {
214 "down" => ("Status Alert", 16711680u32, format!("{url} is down!")),
215 "recovery" => ("Status Recovery", 65280u32, format!("{url} is back up!")),
216 _ => anyhow::bail!("unknown kind: {kind}"),
217 };
218 let payload = json!({
219 "username": "Status",
220 "embeds": [{
221 "title": title,
222 "description": desc,
223 "color": color,
224 "timestamp": chrono::Utc::now().to_rfc3339(),
225 }],
226 });
227 let client = reqwest::Client::builder()
228 .timeout(std::time::Duration::from_secs(5))
229 .build()?;
230 let body = serde_json::to_string(&payload)?;
231 let resp = client
232 .post(webhook)
233 .header("content-type", "application/json")
234 .body(body)
235 .send()
236 .await?;
237 if !resp.status().is_success() {
238 anyhow::bail!("discord {} {}", resp.status(), resp.text().await.unwrap_or_default());
239 }
240 Ok(())
241}