use std::io::Write;
use std::process::{Command, Stdio};
use std::time::Duration;
pub const DEFAULT_THRESHOLD_SECS: u64 = 30;
pub const SUMMARY_MAX_CHARS: usize = 80;
#[derive(Debug, Clone, Default)]
pub struct NotifySettings {
pub enabled: bool,
pub threshold: Duration,
pub email: Option<EmailConfig>,
pub hooks: crate::hooks::HookSet,
pub quiet: bool,
}
#[derive(Debug, Clone)]
pub struct EmailConfig {
pub smtp_host: String,
pub smtp_port: u16,
pub from: String,
pub to: String,
pub username: Option<String>,
}
pub fn should_notify(
enabled: bool,
machine_format: bool,
stderr_is_tty: bool,
elapsed: Duration,
threshold: Duration,
) -> bool {
enabled && !machine_format && stderr_is_tty && elapsed >= threshold
}
fn sanitize_summary(s: &str, max_chars: usize) -> String {
let cleaned: String = s.chars().filter(|c| !c.is_control()).collect();
let trimmed = cleaned.trim();
if trimmed.chars().count() > max_chars {
let truncated: String = trimmed.chars().take(max_chars).collect();
format!("{truncated}…")
} else {
trimmed.to_string()
}
}
pub fn build_payload(model: &str, elapsed: Duration, reply_preview: &str) -> (String, String) {
let title = "supercode: turn finished".to_string();
let secs = elapsed.as_secs();
let summary = sanitize_summary(reply_preview, SUMMARY_MAX_CHARS);
let body = if summary.is_empty() {
format!("{model} · {secs}s")
} else {
format!("{model} · {secs}s · {summary}")
};
(title, body)
}
pub fn fire_desktop(title: &str, body: &str) {
let child = Command::new("notify-send")
.arg("--app-name=supercode")
.arg(title)
.arg(body)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn();
if let Ok(mut child) = child {
std::thread::spawn(move || {
let _ = child.wait();
});
}
}
pub fn ring_bell() {
let mut err = std::io::stderr();
let _ = err.write_all(b"\x07");
let _ = err.flush();
}
pub fn fire_email(cfg: EmailConfig, subject: &str, body: &str) {
let subject = subject.to_string();
let body = body.to_string();
std::thread::spawn(move || {
let _ = send_email_blocking(&cfg, &subject, &body);
});
}
pub fn maybe_fire(
settings: &NotifySettings,
machine_format: bool,
stderr_is_tty: bool,
elapsed: Duration,
model: &str,
reply_preview: &str,
) {
if !machine_format {
crate::hooks::fire_notification(
&settings.hooks,
model,
elapsed.as_secs(),
reply_preview,
settings.quiet,
);
}
if !should_notify(
settings.enabled,
machine_format,
stderr_is_tty,
elapsed,
settings.threshold,
) {
return;
}
let (title, body) = build_payload(model, elapsed, reply_preview);
ring_bell();
fire_desktop(&title, &body);
if let Some(email) = &settings.email {
fire_email(email.clone(), &title, &body);
}
}
fn write_line(w: &mut impl Write, line: &str) -> std::io::Result<()> {
w.write_all(line.as_bytes())?;
w.write_all(b"\r\n")?;
w.flush()
}
fn read_reply(r: &mut impl std::io::BufRead) -> std::io::Result<u32> {
let mut code: u32;
loop {
let mut line = String::new();
if r.read_line(&mut line)? == 0 {
return Err(std::io::Error::new(
std::io::ErrorKind::UnexpectedEof,
"SMTP connection closed mid-reply",
));
}
let bytes = line.as_bytes();
if bytes.len() < 4 {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"malformed SMTP reply line",
));
}
code = std::str::from_utf8(&bytes[..3])
.ok()
.and_then(|s| s.parse().ok())
.ok_or_else(|| {
std::io::Error::new(std::io::ErrorKind::InvalidData, "non-numeric SMTP code")
})?;
let last = bytes[3] != b'-'; if last {
break;
}
}
if !(200..400).contains(&code) {
return Err(std::io::Error::other(format!(
"SMTP command rejected, code {code}"
)));
}
Ok(code)
}
fn send_email_blocking(cfg: &EmailConfig, subject: &str, body: &str) -> std::io::Result<()> {
use base64::Engine;
use std::io::BufReader;
use std::net::{TcpStream, ToSocketAddrs};
let addr = (cfg.smtp_host.as_str(), cfg.smtp_port)
.to_socket_addrs()?
.next()
.ok_or_else(|| {
std::io::Error::new(std::io::ErrorKind::NotFound, "SMTP host did not resolve")
})?;
let stream = TcpStream::connect_timeout(&addr, Duration::from_secs(5))?;
stream.set_read_timeout(Some(Duration::from_secs(5)))?;
stream.set_write_timeout(Some(Duration::from_secs(5)))?;
let mut reader = BufReader::new(stream.try_clone()?);
let mut writer = stream;
read_reply(&mut reader)?; write_line(&mut writer, "EHLO supercode.local")?;
read_reply(&mut reader)?;
if let Some(user) = &cfg.username {
let password = std::env::var("SUPERCODE_NOTIFY_EMAIL_PASSWORD").unwrap_or_default();
let b64 = base64::engine::general_purpose::STANDARD;
write_line(&mut writer, "AUTH LOGIN")?;
read_reply(&mut reader)?;
write_line(&mut writer, &b64.encode(user))?;
read_reply(&mut reader)?;
write_line(&mut writer, &b64.encode(password))?;
read_reply(&mut reader)?;
}
write_line(&mut writer, &format!("MAIL FROM:<{}>", cfg.from))?;
read_reply(&mut reader)?;
write_line(&mut writer, &format!("RCPT TO:<{}>", cfg.to))?;
read_reply(&mut reader)?;
write_line(&mut writer, "DATA")?;
read_reply(&mut reader)?;
write_line(&mut writer, &format!("Subject: {subject}"))?;
write_line(&mut writer, &format!("From: {}", cfg.from))?;
write_line(&mut writer, &format!("To: {}", cfg.to))?;
write_line(&mut writer, "")?;
write_line(&mut writer, body)?;
write_line(&mut writer, ".")?;
read_reply(&mut reader)?;
write_line(&mut writer, "QUIT")?;
let _ = read_reply(&mut reader);
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn long() -> Duration {
Duration::from_secs(60)
}
fn threshold() -> Duration {
Duration::from_secs(30)
}
#[test]
fn fires_when_everything_lines_up() {
assert!(should_notify(true, false, true, long(), threshold()));
}
#[test]
fn off_by_default_when_not_enabled() {
assert!(!should_notify(false, false, true, long(), threshold()));
}
#[test]
fn never_fires_for_machine_output_format() {
assert!(!should_notify(true, true, true, long(), threshold()));
}
#[test]
fn never_fires_when_stderr_is_not_a_tty() {
assert!(!should_notify(true, false, false, long(), threshold()));
}
#[test]
fn never_fires_under_the_threshold() {
let short = Duration::from_secs(5);
assert!(!should_notify(true, false, true, short, threshold()));
}
#[test]
fn fires_exactly_at_the_threshold() {
assert!(should_notify(true, false, true, threshold(), threshold()));
}
#[test]
fn multiple_suppressors_still_suppress() {
assert!(!should_notify(
false,
true,
false,
Duration::from_secs(1),
threshold()
));
}
#[test]
fn sanitize_strips_control_characters() {
let evil = "hello\x1b]0;pwned\x07world";
let clean = sanitize_summary(evil, 100);
assert!(!clean.contains('\x1b'));
assert!(!clean.contains('\x07'));
assert_eq!(clean, "hello]0;pwnedworld");
}
#[test]
fn sanitize_truncates_long_summaries() {
let long_text = "x".repeat(200);
let clean = sanitize_summary(&long_text, 10);
assert_eq!(clean.chars().count(), 11); assert!(clean.ends_with('…'));
}
#[test]
fn sanitize_leaves_short_text_untouched() {
assert_eq!(sanitize_summary(" hi there ", 80), "hi there");
}
#[test]
fn build_payload_includes_model_and_elapsed() {
let (title, body) = build_payload("opus", Duration::from_secs(42), "done!");
assert!(title.contains("supercode"));
assert!(body.contains("opus"));
assert!(body.contains("42s"));
assert!(body.contains("done!"));
}
#[test]
fn build_payload_never_leaks_more_than_a_safe_summary() {
let full_reply = "secret internal detail ".repeat(50);
let (_, body) = build_payload("m", Duration::from_secs(1), &full_reply);
assert!(body.len() < full_reply.len());
}
#[test]
fn build_payload_omits_the_separator_for_an_empty_summary() {
let (_, body) = build_payload("m", Duration::from_secs(3), "");
assert_eq!(body, "m · 3s");
}
#[test]
fn fire_desktop_does_not_panic_when_notify_send_is_absent() {
let old_path = std::env::var("PATH").ok();
std::env::set_var("PATH", "/nonexistent-supercode-test-path");
fire_desktop("title", "body");
if let Some(p) = old_path {
std::env::set_var("PATH", p);
}
}
#[test]
fn ring_bell_does_not_panic() {
ring_bell();
}
fn spawn_fake_smtp_server() -> (u16, std::sync::mpsc::Receiver<Vec<String>>) {
use std::io::{BufRead, BufReader};
use std::net::TcpListener;
use std::sync::mpsc;
let listener = TcpListener::bind("127.0.0.1:0").expect("bind fake SMTP server");
let port = listener.local_addr().unwrap().port();
let (tx, rx) = mpsc::channel();
std::thread::spawn(move || {
let (sock, _) = listener.accept().expect("accept one connection");
let mut writer = sock.try_clone().expect("clone socket");
let mut reader = BufReader::new(sock);
let mut received = Vec::new();
let mut in_data = false;
let _ = write_line(&mut writer, "220 fake.smtp ready");
loop {
let mut line = String::new();
let n = reader.read_line(&mut line).unwrap_or(0);
if n == 0 {
break;
}
let trimmed = line.trim_end().to_string();
let is_quit = trimmed.eq_ignore_ascii_case("QUIT");
let is_data_end = trimmed == ".";
received.push(trimmed.clone());
if in_data && !is_data_end {
continue;
} else if is_quit {
let _ = write_line(&mut writer, "221 bye");
break;
} else if trimmed.eq_ignore_ascii_case("DATA") {
in_data = true;
let _ = write_line(&mut writer, "354 go ahead");
} else if is_data_end {
in_data = false;
let _ = write_line(&mut writer, "250 OK queued");
} else {
let _ = write_line(&mut writer, "250 OK");
}
}
let _ = tx.send(received);
});
(port, rx)
}
#[test]
fn send_email_blocking_speaks_the_expected_smtp_conversation() {
let (port, rx) = spawn_fake_smtp_server();
let cfg = EmailConfig {
smtp_host: "127.0.0.1".to_string(),
smtp_port: port,
from: "supercode@example.test".to_string(),
to: "you@example.test".to_string(),
username: None,
};
send_email_blocking(&cfg, "supercode: turn finished", "opus · 42s · done!")
.expect("fake SMTP conversation should succeed");
let received = rx
.recv_timeout(Duration::from_secs(5))
.expect("fake server should have recorded the conversation");
assert!(received.iter().any(|l| l.starts_with("EHLO")));
assert!(received
.iter()
.any(|l| l == "MAIL FROM:<supercode@example.test>"));
assert!(received.iter().any(|l| l == "RCPT TO:<you@example.test>"));
assert!(received.iter().any(|l| l == "DATA"));
assert!(received
.iter()
.any(|l| l == "Subject: supercode: turn finished"));
assert!(received.iter().any(|l| l.contains("opus · 42s · done!")));
assert!(received.iter().any(|l| l == "."));
assert!(received.iter().any(|l| l.eq_ignore_ascii_case("QUIT")));
}
#[test]
fn send_email_blocking_sends_auth_login_when_username_configured() {
let (port, rx) = spawn_fake_smtp_server();
let cfg = EmailConfig {
smtp_host: "127.0.0.1".to_string(),
smtp_port: port,
from: "supercode@example.test".to_string(),
to: "you@example.test".to_string(),
username: Some("bot".to_string()),
};
std::env::set_var("SUPERCODE_NOTIFY_EMAIL_PASSWORD", "s3cret");
send_email_blocking(&cfg, "subject", "body").expect("auth conversation should succeed");
std::env::remove_var("SUPERCODE_NOTIFY_EMAIL_PASSWORD");
let received = rx.recv_timeout(Duration::from_secs(5)).expect("recorded");
assert!(received.iter().any(|l| l == "AUTH LOGIN"));
use base64::Engine;
let b64 = base64::engine::general_purpose::STANDARD;
assert!(received.iter().any(|l| l == &b64.encode("bot")));
assert!(received.iter().any(|l| l == &b64.encode("s3cret")));
}
#[test]
fn send_email_blocking_fails_cleanly_on_connection_refused() {
let listener =
std::net::TcpListener::bind("127.0.0.1:0").expect("bind to find a free port");
let port = listener.local_addr().unwrap().port();
drop(listener);
let cfg = EmailConfig {
smtp_host: "127.0.0.1".to_string(),
smtp_port: port,
from: "a@example.test".to_string(),
to: "b@example.test".to_string(),
username: None,
};
let result = send_email_blocking(&cfg, "s", "b");
assert!(result.is_err());
}
#[test]
fn maybe_fire_is_a_true_no_op_when_disabled() {
let settings = NotifySettings {
enabled: false,
threshold: Duration::from_secs(0),
email: None,
..Default::default()
};
maybe_fire(&settings, false, true, Duration::from_secs(999), "m", "r");
}
#[test]
fn maybe_fire_skips_machine_format_even_when_enabled() {
let settings = NotifySettings {
enabled: true,
threshold: Duration::from_secs(0),
email: None,
..Default::default()
};
maybe_fire(&settings, true, true, Duration::from_secs(999), "m", "r");
}
}