repos
/ status-rust master

status-rust

mirror archived upstream

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

8.7 KB · 242 lines · Rust Raw History
  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}