8.7 KB
raw
use anyhow::Context;
use hickory_resolver::TokioAsyncResolver;
use lettre::message::header::ContentType;
use lettre::transport::smtp::client::TlsParameters;
use lettre::{AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor};
use minijinja::{AutoEscape, Environment};
use serde_json::json;
use uuid::Uuid;
const FROM_ADDR: &str = "noreply@bythewood.me";
const HELO: &str = "bythewood.me";
const SMTP_TIMEOUT_SECS: u64 = 30;
const EMAIL_BASE_TEMPLATE: &str =
include_str!("../templates/emails/property_email_base.html");
/// Property snapshot used to fill in the email metadata table. Computed at the
/// moment the alert fires so the values match what triggered it.
pub struct EmailContext {
pub id: Uuid,
pub name: String,
pub url: String,
pub current_status: i64,
pub avg_response_time: i64,
}
struct Theme {
email_title: &'static str,
preheader: String,
status_label: &'static str,
event_title: &'static str,
event_copy: &'static str,
accent: &'static str,
accent_bright: &'static str,
accent_tint: &'static str,
accent_border: &'static str,
}
fn theme_for(kind: &str, name: &str) -> Option<Theme> {
match kind {
"down" => Some(Theme {
email_title: "Property down",
preheader: format!("{name} is not responding"),
status_label: "Down",
event_title: "Your property is down.",
event_copy: "We've observed two consecutive failed checks in a row. \
Monitoring will continue in the background and you'll get \
another note the moment it recovers.",
accent: "#c47055",
accent_bright: "#e38871",
accent_tint: "#201712",
accent_border: "#36231b",
}),
"recovery" => Some(Theme {
email_title: "Property recovered",
preheader: format!("{name} is back online"),
status_label: "Recovered",
event_title: "Your property is back online.",
event_copy: "The latest check returned a healthy response. Everything \
looks normal again; we'll keep monitoring and alert you if \
anything changes.",
accent: "#6b9e78",
accent_bright: "#7db88c",
accent_tint: "#191e17",
accent_border: "#222d22",
}),
_ => None,
}
}
pub(crate) fn render_preview_html(kind: &str, base_url: &str) -> anyhow::Result<String> {
let theme = theme_for(kind, "example.com")
.ok_or_else(|| anyhow::anyhow!("unknown kind: {kind} (use 'down' or 'recovery')"))?;
let ctx = EmailContext {
id: Uuid::nil(),
name: "example.com".to_string(),
url: "https://example.com".to_string(),
current_status: if kind == "down" { 503 } else { 200 },
avg_response_time: 184,
};
render_email_html(&theme, &ctx, base_url)
}
fn render_email_html(theme: &Theme, ctx: &EmailContext, base_url: &str) -> anyhow::Result<String> {
let mut env = Environment::new();
env.set_auto_escape_callback(|_| AutoEscape::Html);
env.add_template("email_base.html", EMAIL_BASE_TEMPLATE)
.context("compiling email template")?;
let tmpl = env.get_template("email_base.html")?;
let html = tmpl
.render(minijinja::context! {
email_title => theme.email_title,
preheader => &theme.preheader,
status_label => theme.status_label,
event_title => theme.event_title,
event_copy => theme.event_copy,
accent => theme.accent,
accent_bright => theme.accent_bright,
accent_tint => theme.accent_tint,
accent_border => theme.accent_border,
BASE_URL => base_url.trim_end_matches('/'),
property => minijinja::context! {
id => ctx.id.to_string(),
name => &ctx.name,
url => &ctx.url,
current_status => ctx.current_status,
avg_response_time => ctx.avg_response_time,
},
})
.context("rendering email template")?;
Ok(html)
}
/// Fire a state-transition notification. `kind` is "down" or "recovery".
pub async fn fire(
kind: &str,
ctx: &EmailContext,
base_url: &str,
alert_email: Option<&str>,
discord_webhook: Option<&str>,
) -> anyhow::Result<()> {
let theme = theme_for(kind, &ctx.name).ok_or_else(|| anyhow::anyhow!("unknown alert kind: {kind}"))?;
let subject = match kind {
"down" => format!("Status: {} is down!", ctx.name),
"recovery" => format!("Status: {} is back up!", ctx.name),
_ => unreachable!(),
};
let html = render_email_html(&theme, ctx, base_url)?;
let mut errors: Vec<anyhow::Error> = Vec::new();
if let Some(to) = alert_email {
if let Err(e) = send_email_via_mx(&subject, &html, to).await {
errors.push(e.context("email"));
}
}
if let Some(webhook) = discord_webhook {
if let Err(e) = send_discord(kind, &ctx.url, webhook).await {
errors.push(e.context("discord"));
}
}
if !errors.is_empty() {
let combined = errors.iter().map(|e| format!("{e:#}")).collect::<Vec<_>>().join("; ");
anyhow::bail!("{combined}");
}
Ok(())
}
/// Direct-MX delivery: resolve the recipient domain's MX records, sort by
/// preference, try each with STARTTLS. No relay configured by default.
async fn send_email_via_mx(subject: &str, html: &str, to: &str) -> anyhow::Result<()> {
let domain = to
.rsplit_once('@')
.map(|(_, d)| d.to_string())
.ok_or_else(|| anyhow::anyhow!("invalid recipient: {to}"))?;
let resolver = TokioAsyncResolver::tokio_from_system_conf()
.context("creating dns resolver")?;
let mx = resolver
.mx_lookup(domain.as_str())
.await
.context("mx lookup")?;
let mut records: Vec<_> = mx.iter().collect();
records.sort_by_key(|r| r.preference());
if records.is_empty() {
anyhow::bail!("no MX records for {domain}");
}
let email = Message::builder()
.from(FROM_ADDR.parse()?)
.to(to.parse()?)
.subject(subject)
.header(ContentType::TEXT_HTML)
.body(html.to_string())?;
let mut last_err: Option<anyhow::Error> = None;
for rec in records {
let host = rec.exchange().to_utf8();
let host = host.trim_end_matches('.').to_string();
match try_one_mx(&host, &email).await {
Ok(()) => return Ok(()),
Err(e) => {
tracing::warn!("MX {host} failed for {domain}: {e:#}");
last_err = Some(e);
}
}
}
Err(last_err.unwrap_or_else(|| anyhow::anyhow!("all MX hosts failed for {domain}")))
}
async fn try_one_mx(host: &str, email: &Message) -> anyhow::Result<()> {
let tls = TlsParameters::builder(host.to_string())
// Receiving MTAs sometimes use self-signed/expired certs; opportunistic
// STARTTLS with relaxed validation matches what the Python smtplib
// version did. End-to-end privacy is the recipient's MTA's job.
.dangerous_accept_invalid_certs(true)
.dangerous_accept_invalid_hostnames(true)
.build_rustls()?;
let transport: AsyncSmtpTransport<Tokio1Executor> =
AsyncSmtpTransport::<Tokio1Executor>::builder_dangerous(host)
.port(25)
.hello_name(lettre::transport::smtp::extension::ClientId::Domain(HELO.into()))
.timeout(Some(std::time::Duration::from_secs(SMTP_TIMEOUT_SECS)))
.tls(lettre::transport::smtp::client::Tls::Opportunistic(tls))
.build();
transport.send(email.clone()).await.context("smtp send")?;
Ok(())
}
async fn send_discord(kind: &str, url: &str, webhook: &str) -> anyhow::Result<()> {
let (title, color, desc) = match kind {
"down" => ("Status Alert", 16711680u32, format!("{url} is down!")),
"recovery" => ("Status Recovery", 65280u32, format!("{url} is back up!")),
_ => anyhow::bail!("unknown kind: {kind}"),
};
let payload = json!({
"username": "Status",
"embeds": [{
"title": title,
"description": desc,
"color": color,
"timestamp": chrono::Utc::now().to_rfc3339(),
}],
});
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(5))
.build()?;
let body = serde_json::to_string(&payload)?;
let resp = client
.post(webhook)
.header("content-type", "application/json")
.body(body)
.send()
.await?;
if !resp.status().is_success() {
anyhow::bail!("discord {} {}", resp.status(), resp.text().await.unwrap_or_default());
}
Ok(())
}