thirdshift 0.13.0

Turn a GitHub issue into a ready-for-review pull request, unattended
//! Email through Resend's HTTP API (ADR 0005): one HTTPS POST per email, with
//! the key `resend_key` finds. `thirdshift email-test` and Run
//! notifications both send through it, after the same checks.

use std::time::Duration;

use anyhow::{Context, Result, bail};
use chrono::{Local, SecondsFormat};
use serde_json::{Value, json};

use crate::config::EmailSettings;
use crate::host;
use crate::resend_key::ResendKey;

/// Resend's shared sender, used when `email.from` isn't set. Resend only lets
/// it send to the address of the user's own Resend account.
pub const DEFAULT_FROM: &str = "onboarding@resend.dev";

const RESEND_URL: &str = "https://api.resend.com";

/// How long a send may take before thirdshift gives up on it.
const TIMEOUT: Duration = Duration::from_secs(30);

/// Resend, ready to send to one address. Making one checks everything that
/// can be checked locally; nothing is sent to validate the key.
pub struct Resend {
    key: ResendKey,
    /// The API's base URL: Resend's own, unless `THIRDSHIFT_RESEND_URL`
    /// overrides it, which only the tests do.
    url: String,
    from: String,
    to: String,
}

impl Resend {
    /// Resend, sending to `to`, else to `email.to`, from `email.from`, else
    /// from Resend's shared sender. Fails naming each thing that is missing:
    /// the address, the Resend API key, or both, or naming what's wrong with
    /// the Credentials.
    pub fn new(to: Option<String>, settings: &EmailSettings) -> Result<Self> {
        let to = to
            .or_else(|| settings.to.clone())
            .filter(|to| !to.is_empty());
        let (to, key) = match (to, ResendKey::require()) {
            (Some(to), Ok(key)) => (to, key),
            (to, key) => {
                let mut missing = Vec::new();
                if to.is_none() {
                    missing.push(
                        "no email address: give one, or set email.to in the User config".into(),
                    );
                }
                if let Err(error) = key {
                    missing.push(format!("{error:#}"));
                }
                bail!("{}\nnothing sent", missing.join("\n"));
            }
        };
        let url = std::env::var("THIRDSHIFT_RESEND_URL")
            .ok()
            .filter(|url| !url.is_empty())
            .unwrap_or_else(|| RESEND_URL.into());
        Ok(Resend {
            key,
            url: url.trim_end_matches('/').to_string(),
            from: settings.from.clone().unwrap_or_else(|| DEFAULT_FROM.into()),
            to,
        })
    }

    /// The sender every email goes out as.
    pub fn from(&self) -> &str {
        &self.from
    }

    /// Send one plain-text email. Resend accepting it is all this can tell:
    /// on a refusal, the error is Resend's own text, word for word, and where
    /// the key came from, since `RESEND_API_KEY` hides the Credentials' key.
    pub fn send(&self, subject: &str, text: &str) -> Result<()> {
        let client = reqwest::blocking::Client::builder()
            .timeout(TIMEOUT)
            .build()
            .context("can't set up the HTTPS client")?;
        let response = client
            .post(format!("{}/emails", self.url))
            .bearer_auth(&self.key.secret)
            .json(&json!({
                "from": self.from,
                "to": self.to,
                "subject": subject,
                "text": text,
            }))
            .send()
            .context("can't reach Resend")?;
        let status = response.status();
        if status.is_success() {
            return Ok(());
        }
        let body = response.text().unwrap_or_default();
        let source = &self.key.source;
        match error_text(&body) {
            text if text.is_empty() => {
                bail!("Resend refused the email ({status}), sent with the key from {source}")
            }
            text => {
                bail!("Resend refused the email ({status}): {text}; the key came from {source}")
            }
        }
    }
}

/// The error text in a reply Resend refused with: its `message`, or the whole
/// reply if it has none.
fn error_text(body: &str) -> String {
    serde_json::from_str::<Value>(body)
        .ok()
        .and_then(|reply| reply["message"].as_str().map(String::from))
        .unwrap_or_else(|| body.trim().to_string())
}

/// `thirdshift email-test`: send a test email to `to`, else to `email.to`.
/// Returns the line that says Resend took it.
pub fn send_test(to: Option<String>, settings: &EmailSettings) -> Result<&'static str> {
    let resend = Resend::new(to, settings)?;
    let host = host::name();
    let host = host.as_deref().unwrap_or("unknown host");
    let time = Local::now().to_rfc3339_opts(SecondsFormat::Secs, false);
    resend.send(
        &format!("thirdshift test email from {host}"),
        &test_text(host, &time, resend.from()),
    )?;
    Ok("accepted by Resend; check your inbox")
}

/// The body of the test email.
fn test_text(host: &str, time: &str, from: &str) -> String {
    format!(
        "This is a test email from `thirdshift email-test`.\n\n\
         Host:   {host}\n\
         Time:   {time}\n\
         Sender: {from}\n"
    )
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn error_text_is_the_message_or_the_whole_reply() {
        assert_eq!(
            error_text(
                r#"{"statusCode":401,"message":"API key is invalid","name":"validation_error"}"#
            ),
            "API key is invalid"
        );
        assert_eq!(error_text("Bad Gateway\n"), "Bad Gateway");
        assert_eq!(error_text(r#"{"error":"x"}"#), r#"{"error":"x"}"#);
        assert_eq!(error_text(""), "");
    }

    #[test]
    fn the_test_text_shows_host_time_and_sender() {
        let text = test_text("droplet-1", "2026-09-29T08:00:00-04:00", "a@b.dev");
        for line in [
            "Host:   droplet-1",
            "Time:   2026-09-29T08:00:00-04:00",
            "Sender: a@b.dev",
        ] {
            assert!(text.contains(line), "{text}");
        }
    }
}