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;
pub const DEFAULT_FROM: &str = "onboarding@resend.dev";
const RESEND_URL: &str = "https://api.resend.com";
const TIMEOUT: Duration = Duration::from_secs(30);
pub struct Resend {
key: ResendKey,
url: String,
from: String,
to: String,
}
impl Resend {
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,
})
}
pub fn from(&self) -> &str {
&self.from
}
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}")
}
}
}
}
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())
}
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")
}
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}");
}
}
}