use std::fs;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::thread;
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use chrono::Utc;
use crate::infra::config::RuntimeConfig;
const DEFAULT_SEND_TIMEOUT: Duration = Duration::from_secs(60);
const EXECUTABLE_BUSY_RETRY_ATTEMPTS: u8 = 5;
const EXECUTABLE_BUSY_RETRY_DELAY: Duration = Duration::from_millis(10);
const OUTBOX_DIR_NAME: &str = "reply-outbox";
const GIT_SENDEMAIL_FROM_ARGS: &[&str] = &["config", "sendemail.from"];
const GIT_USER_NAME_LOOKUP_ARGS: &[&str] = &["config", "user.name"];
const GIT_USER_EMAIL_LOOKUP_ARGS: &[&str] = &["config", "user.email"];
#[derive(Debug, Clone)]
pub struct GitSendEmailCheck {
pub status: GitSendEmailStatus,
}
#[derive(Debug, Clone)]
pub enum GitSendEmailStatus {
Available { path: PathBuf, version: String },
Broken { path: PathBuf, reason: String },
Missing,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ReplyIdentitySource {
SendEmailFrom,
UserNameEmail,
}
impl ReplyIdentitySource {
pub fn as_str(self) -> &'static str {
match self {
Self::SendEmailFrom => "git config sendemail.from",
Self::UserNameEmail => "git config user.name/user.email",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ReplyIdentity {
pub display: String,
pub email: String,
pub source: ReplyIdentitySource,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SendRequest {
pub mail_id: i64,
pub thread_id: i64,
pub from: String,
pub to: Vec<String>,
pub cc: Vec<String>,
pub subject: String,
pub in_reply_to: String,
pub references: Vec<String>,
pub body: String,
pub preview_confirmed_at: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SendStatus {
Sent,
Failed,
TimedOut,
}
#[derive(Debug, Clone)]
pub struct SendOutcome {
pub transport: String,
pub message_id: String,
pub command_line: Option<String>,
pub draft_path: Option<PathBuf>,
pub exit_code: Option<i32>,
pub timed_out: bool,
pub stdout: String,
pub stderr: String,
pub error_summary: Option<String>,
pub started_at: String,
pub finished_at: String,
pub status: SendStatus,
}
pub fn check() -> GitSendEmailCheck {
check_with_command_path(None)
}
pub fn resolve_reply_identity() -> std::result::Result<ReplyIdentity, String> {
resolve_reply_identity_with_command_path(None)
}
pub fn send(runtime: &RuntimeConfig, request: &SendRequest) -> SendOutcome {
send_with_command_path(runtime, request, None)
}
fn check_with_command_path(command_path: Option<&Path>) -> GitSendEmailCheck {
let mut last_failure: Option<(PathBuf, String)> = None;
for candidate in git_candidates(command_path) {
match probe_send_email(&candidate) {
Probe::Available { path, version, .. } => {
return GitSendEmailCheck {
status: GitSendEmailStatus::Available { path, version },
};
}
Probe::Broken { path, reason } => {
last_failure = Some((path, reason));
}
Probe::Missing => {}
}
}
if let Some((path, reason)) = last_failure {
GitSendEmailCheck {
status: GitSendEmailStatus::Broken { path, reason },
}
} else {
GitSendEmailCheck {
status: GitSendEmailStatus::Missing,
}
}
}
fn resolve_reply_identity_with_command_path(
command_path: Option<&Path>,
) -> std::result::Result<ReplyIdentity, String> {
let resolved = resolve_git_binary(command_path)?;
if let Some(value) = git_config_value(&resolved.command, GIT_SENDEMAIL_FROM_ARGS)? {
let identity = parse_identity(&value).ok_or_else(|| {
"git config sendemail.from is set but does not contain a valid email address"
.to_string()
})?;
return Ok(ReplyIdentity {
display: identity.display,
email: identity.email,
source: ReplyIdentitySource::SendEmailFrom,
});
}
let email =
git_config_value(&resolved.command, GIT_USER_EMAIL_LOOKUP_ARGS)?.ok_or_else(|| {
"git email identity missing; set git config sendemail.from or user.email".to_string()
})?;
let name = git_config_value(&resolved.command, GIT_USER_NAME_LOOKUP_ARGS)?;
let display = if let Some(name) = name {
let trimmed = name.trim();
if trimmed.is_empty() {
email.clone()
} else {
format!("{trimmed} <{email}>")
}
} else {
email.clone()
};
Ok(ReplyIdentity {
display,
email,
source: ReplyIdentitySource::UserNameEmail,
})
}
fn send_with_command_path(
runtime: &RuntimeConfig,
request: &SendRequest,
command_path: Option<&Path>,
) -> SendOutcome {
send_with_options(runtime, request, command_path, DEFAULT_SEND_TIMEOUT)
}
fn send_with_options(
runtime: &RuntimeConfig,
request: &SendRequest,
command_path: Option<&Path>,
timeout: Duration,
) -> SendOutcome {
let started_at = now_timestamp();
let message_id = generate_message_id(&request.from);
let draft_dir = runtime.data_dir.join(OUTBOX_DIR_NAME);
let draft_name = format!(
"reply-{}-{}.eml",
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_nanos(),
std::process::id()
);
let draft_path = draft_dir.join(draft_name);
let resolved = match resolve_git_command(command_path) {
Ok(resolved) => resolved,
Err(error) => {
return failed_outcome(
message_id,
started_at,
None,
None,
format!("git send-email unavailable: {error}"),
);
}
};
if let Err(error) = fs::create_dir_all(&draft_dir) {
return failed_outcome(
message_id,
started_at,
None,
None,
format!(
"failed to create reply outbox {}: {error}",
draft_dir.display()
),
);
}
let rendered = render_message_file(request, &message_id);
if let Err(error) = fs::write(&draft_path, rendered) {
return failed_outcome(
message_id,
started_at,
None,
None,
format!(
"failed to write reply draft {}: {error}",
draft_path.display()
),
);
}
let draft_path = stabilize_child_path(&draft_path);
let command_line = render_command_line(
&resolved.display_name,
&build_send_email_args(request, &draft_path),
);
let mut command = Command::new(&resolved.command);
command.args(build_send_email_args(request, &draft_path));
command
.current_dir(resolve_working_dir(runtime))
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.env("GIT_TERMINAL_PROMPT", "0");
let mut child = match spawn_command_with_retry(&mut command) {
Ok(child) => child,
Err(error) => {
return failed_outcome(
message_id,
started_at,
Some(command_line),
Some(draft_path),
format!("failed to start git send-email: {error}"),
);
}
};
let start = Instant::now();
let mut timed_out = false;
loop {
match child.try_wait() {
Ok(Some(_)) => break,
Ok(None) => {
if start.elapsed() >= timeout {
timed_out = true;
let _ = child.kill();
break;
}
thread::sleep(Duration::from_millis(30));
}
Err(error) => {
return failed_outcome(
message_id,
started_at,
Some(command_line),
Some(draft_path),
format!("failed while waiting for git send-email: {error}"),
);
}
}
}
let output = match child.wait_with_output() {
Ok(output) => output,
Err(error) => {
return failed_outcome(
message_id,
started_at,
Some(command_line),
Some(draft_path),
format!("failed to collect git send-email output: {error}"),
);
}
};
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
let finished_at = now_timestamp();
if timed_out {
return SendOutcome {
transport: "git-send-email".to_string(),
message_id,
command_line: Some(command_line),
draft_path: Some(draft_path),
exit_code: output.status.code(),
timed_out: true,
stdout,
stderr,
error_summary: Some(format!(
"git send-email timed out after {}s",
timeout.as_secs()
)),
started_at,
finished_at,
status: SendStatus::TimedOut,
};
}
if output.status.success() {
let _ = fs::remove_file(&draft_path);
return SendOutcome {
transport: "git-send-email".to_string(),
message_id,
command_line: Some(command_line),
draft_path: None,
exit_code: output.status.code(),
timed_out: false,
stdout,
stderr,
error_summary: None,
started_at,
finished_at,
status: SendStatus::Sent,
};
}
SendOutcome {
transport: "git-send-email".to_string(),
message_id,
command_line: Some(command_line),
draft_path: Some(draft_path),
exit_code: output.status.code(),
timed_out: false,
stdout: stdout.clone(),
stderr: stderr.clone(),
error_summary: summarize_failure(output.status.code(), &stdout, &stderr),
started_at,
finished_at,
status: SendStatus::Failed,
}
}
fn spawn_command_with_retry(command: &mut Command) -> std::io::Result<std::process::Child> {
let mut attempts_remaining = EXECUTABLE_BUSY_RETRY_ATTEMPTS;
loop {
match command.spawn() {
Ok(child) => return Ok(child),
Err(error) if is_retryable_executable_busy(&error) && attempts_remaining > 0 => {
attempts_remaining -= 1;
thread::sleep(EXECUTABLE_BUSY_RETRY_DELAY);
}
Err(error) => return Err(error),
}
}
}
#[derive(Debug, Clone)]
struct ParsedIdentity {
display: String,
email: String,
}
#[derive(Debug, Clone)]
struct ResolvedGitCommand {
command: String,
display_name: String,
}
enum GitCandidate {
Path(PathBuf),
Program(&'static str),
}
enum Probe {
Available {
path: PathBuf,
version: String,
command: String,
},
Broken {
path: PathBuf,
reason: String,
},
Missing,
}
fn git_candidates(command_path: Option<&Path>) -> Vec<GitCandidate> {
let mut candidates = Vec::new();
if let Some(path) = command_path {
candidates.push(GitCandidate::Path(path.to_path_buf()));
} else {
candidates.push(GitCandidate::Program("git"));
}
candidates
}
fn probe_send_email(candidate: &GitCandidate) -> Probe {
match candidate {
GitCandidate::Path(path) => {
if !path.exists() {
return Probe::Missing;
}
run_send_email_probe(path, path, path.display().to_string())
}
GitCandidate::Program(program) => run_send_email_probe(
program,
&PathBuf::from(format!("{program} (PATH)")),
(*program).to_string(),
),
}
}
fn probe_git_binary(candidate: &GitCandidate) -> Probe {
match candidate {
GitCandidate::Path(path) => {
if !path.exists() {
return Probe::Missing;
}
run_probe(path, &["--version"], path, path.display().to_string())
}
GitCandidate::Program(program) => run_probe(
program,
&["--version"],
&PathBuf::from(format!("{program} (PATH)")),
(*program).to_string(),
),
}
}
fn run_probe<T>(command: T, args: &[&str], display_path: &Path, command_text: String) -> Probe
where
T: AsRef<std::ffi::OsStr>,
{
match output_with_retry(Command::new(command).args(args)) {
Ok(output) if output.status.success() => Probe::Available {
path: display_path.to_path_buf(),
version: normalize_output(&output.stdout)
.or_else(|| normalize_output(&output.stderr))
.unwrap_or_else(|| "unknown".to_string()),
command: command_text,
},
Ok(output) => Probe::Broken {
path: display_path.to_path_buf(),
reason: normalize_output(&output.stderr)
.or_else(|| normalize_output(&output.stdout))
.unwrap_or_else(|| format!("exit status {}", output.status)),
},
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Probe::Missing,
Err(error) => Probe::Broken {
path: display_path.to_path_buf(),
reason: error.to_string(),
},
}
}
fn run_send_email_probe<T>(command: T, display_path: &Path, command_text: String) -> Probe
where
T: AsRef<std::ffi::OsStr> + Copy,
{
match output_with_retry(Command::new(command).args(["send-email", "-h"])) {
Ok(output) => {
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
let combined = format!("{stdout}\n{stderr}");
if looks_like_send_email_help(&combined) {
let version = probe_git_version(command)
.unwrap_or_else(|| "git send-email (version unavailable)".to_string());
Probe::Available {
path: display_path.to_path_buf(),
version,
command: command_text,
}
} else {
Probe::Broken {
path: display_path.to_path_buf(),
reason: normalize_output(combined.as_bytes())
.unwrap_or_else(|| format!("exit status {}", output.status)),
}
}
}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Probe::Missing,
Err(error) => Probe::Broken {
path: display_path.to_path_buf(),
reason: error.to_string(),
},
}
}
fn looks_like_send_email_help(output: &str) -> bool {
let lowered = output.to_ascii_lowercase();
lowered.contains("git send-email")
&& (lowered.contains("usage:")
|| lowered.contains("send patches")
|| lowered.contains("<file|directory>"))
&& !lowered.contains("not a git command")
}
fn probe_git_version<T>(command: T) -> Option<String>
where
T: AsRef<std::ffi::OsStr>,
{
let output = output_with_retry(Command::new(command).arg("--version")).ok()?;
if !output.status.success() {
return None;
}
normalize_output(&output.stdout).or_else(|| normalize_output(&output.stderr))
}
fn resolve_git_command(
command_path: Option<&Path>,
) -> std::result::Result<ResolvedGitCommand, String> {
let mut last_failure: Option<(PathBuf, String)> = None;
for candidate in git_candidates(command_path) {
match probe_send_email(&candidate) {
Probe::Available { path, command, .. } => {
return Ok(ResolvedGitCommand {
display_name: path.display().to_string(),
command,
});
}
Probe::Broken { path, reason } => {
last_failure = Some((path, reason));
}
Probe::Missing => {}
}
}
if let Some((path, reason)) = last_failure {
return Err(format!(
"git send-email probe failed for {}: {}",
path.display(),
reason
));
}
Err("git send-email executable not found".to_string())
}
fn resolve_git_binary(
command_path: Option<&Path>,
) -> std::result::Result<ResolvedGitCommand, String> {
let mut last_failure: Option<(PathBuf, String)> = None;
for candidate in git_candidates(command_path) {
match probe_git_binary(&candidate) {
Probe::Available { path, command, .. } => {
return Ok(ResolvedGitCommand {
display_name: path.display().to_string(),
command,
});
}
Probe::Broken { path, reason } => {
last_failure = Some((path, reason));
}
Probe::Missing => {}
}
}
if let Some((path, reason)) = last_failure {
return Err(format!(
"git probe failed for {}: {}",
path.display(),
reason
));
}
Err("git executable not found".to_string())
}
fn git_config_value(command: &str, args: &[&str]) -> std::result::Result<Option<String>, String> {
let output = output_with_retry(Command::new(command).args(args))
.map_err(|error| format!("failed to run git {}: {error}", args.join(" ")))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
if stderr.is_empty() {
return Ok(None);
}
return Err(format!("git {} failed: {stderr}", args.join(" ")));
}
let value = String::from_utf8_lossy(&output.stdout).trim().to_string();
if value.is_empty() {
Ok(None)
} else {
Ok(Some(value))
}
}
fn output_with_retry(command: &mut Command) -> std::io::Result<std::process::Output> {
let mut attempts_remaining = EXECUTABLE_BUSY_RETRY_ATTEMPTS;
loop {
match command.output() {
Ok(output) => return Ok(output),
Err(error) if is_retryable_executable_busy(&error) && attempts_remaining > 0 => {
attempts_remaining -= 1;
thread::sleep(EXECUTABLE_BUSY_RETRY_DELAY);
}
Err(error) => return Err(error),
}
}
}
fn is_retryable_executable_busy(error: &std::io::Error) -> bool {
error.kind() == std::io::ErrorKind::ExecutableFileBusy
}
fn parse_identity(value: &str) -> Option<ParsedIdentity> {
let display = normalize_header_value(value);
let email = extract_email_address(&display)?;
Some(ParsedIdentity { display, email })
}
fn normalize_header_value(value: &str) -> String {
value.split_whitespace().collect::<Vec<_>>().join(" ")
}
fn extract_email_address(value: &str) -> Option<String> {
if let Some((_, tail)) = value.rsplit_once('<')
&& let Some((email, _)) = tail.split_once('>')
{
let normalized = normalize_message_id(email);
if !normalized.is_empty() {
return Some(normalized);
}
}
let candidate = value
.split_whitespace()
.find(|token| token.contains('@'))
.map(normalize_message_id)?;
if candidate.is_empty() {
None
} else {
Some(candidate)
}
}
fn normalize_message_id(value: &str) -> String {
value
.trim()
.trim_matches(|character| matches!(character, '<' | '>' | '"' | ','))
.trim()
.to_string()
}
fn render_message_file(request: &SendRequest, message_id: &str) -> String {
let mut lines = vec![
format!("From: {}", request.from),
format!("To: {}", request.to.join(", ")),
];
if !request.cc.is_empty() {
lines.push(format!("Cc: {}", request.cc.join(", ")));
}
lines.push(format!("Subject: {}", request.subject));
lines.push(format!("Date: {}", Utc::now().to_rfc2822()));
lines.push(format!("Message-ID: <{message_id}>"));
lines.push(format!("In-Reply-To: <{}>", request.in_reply_to));
if !request.references.is_empty() {
lines.push(format!(
"References: {}",
request
.references
.iter()
.map(|value| format!("<{}>", normalize_message_id(value)))
.collect::<Vec<String>>()
.join(" ")
));
}
lines.push("MIME-Version: 1.0".to_string());
lines.push("Content-Type: text/plain; charset=UTF-8".to_string());
lines.push("Content-Transfer-Encoding: 8bit".to_string());
lines.push(String::new());
lines.push(request.body.trim_end_matches('\n').to_string());
lines.push(String::new());
lines.join("\n")
}
fn build_send_email_args(request: &SendRequest, draft_path: &Path) -> Vec<String> {
let mut args = vec![
"send-email".to_string(),
"--confirm=never".to_string(),
"--quiet".to_string(),
"--from".to_string(),
request.from.clone(),
"--subject".to_string(),
request.subject.clone(),
"--in-reply-to".to_string(),
format!("<{}>", request.in_reply_to),
];
for to in &request.to {
args.push("--to".to_string());
args.push(to.clone());
}
for cc in &request.cc {
args.push("--cc".to_string());
args.push(cc.clone());
}
args.push(draft_path.display().to_string());
args
}
fn stabilize_child_path(path: &Path) -> PathBuf {
if path.is_absolute() {
return path.to_path_buf();
}
std::env::current_dir()
.map(|current_dir| current_dir.join(path))
.unwrap_or_else(|_| path.to_path_buf())
}
fn generate_message_id(from: &str) -> String {
let domain = extract_email_address(from)
.and_then(|email| email.split('@').nth(1).map(ToOwned::to_owned))
.unwrap_or_else(|| "localhost".to_string());
let nonce = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_nanos();
format!("criew-{nonce}-{}@{domain}", std::process::id())
}
fn resolve_working_dir(runtime: &RuntimeConfig) -> PathBuf {
runtime
.kernel_trees
.iter()
.find(|path| path.is_dir())
.cloned()
.or_else(|| std::env::current_dir().ok())
.unwrap_or_else(|| runtime.data_dir.clone())
}
fn render_command_line(command: &str, args: &[String]) -> String {
let mut pieces = Vec::with_capacity(args.len() + 1);
pieces.push(render_shell_token(command));
for arg in args {
pieces.push(render_shell_token(arg));
}
pieces.join(" ")
}
fn render_shell_token(token: &str) -> String {
if token.is_empty() {
return "''".to_string();
}
if token
.chars()
.all(|character| character.is_ascii_alphanumeric() || "_-./:@".contains(character))
{
return token.to_string();
}
format!("'{}'", token.replace('\'', "'\\''"))
}
fn normalize_output(bytes: &[u8]) -> Option<String> {
let text = String::from_utf8_lossy(bytes);
text.lines()
.map(str::trim)
.find(|line| !line.is_empty())
.map(ToOwned::to_owned)
}
fn summarize_failure(exit_code: Option<i32>, stdout: &str, stderr: &str) -> Option<String> {
normalize_output(stderr.as_bytes())
.or_else(|| normalize_output(stdout.as_bytes()))
.or_else(|| exit_code.map(|code| format!("git send-email exited with {code}")))
}
fn failed_outcome(
message_id: String,
started_at: String,
command_line: Option<String>,
draft_path: Option<PathBuf>,
error_summary: String,
) -> SendOutcome {
SendOutcome {
transport: "git-send-email".to_string(),
message_id,
command_line,
draft_path,
exit_code: None,
timed_out: false,
stdout: String::new(),
stderr: String::new(),
error_summary: Some(error_summary),
started_at,
finished_at: now_timestamp(),
status: SendStatus::Failed,
}
}
fn now_timestamp() -> String {
Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true)
}
#[cfg(test)]
mod tests {
use std::fs;
use std::io::Write;
use std::os::unix::fs::PermissionsExt;
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
use crate::infra::config::RuntimeConfig;
use super::{
GitSendEmailStatus, ReplyIdentitySource, SendRequest, SendStatus, check_with_command_path,
extract_email_address, generate_message_id, normalize_message_id, normalize_output,
render_command_line, render_message_file, resolve_reply_identity_with_command_path,
resolve_working_dir, send_with_command_path, send_with_options, stabilize_child_path,
summarize_failure,
};
fn temp_dir(label: &str) -> PathBuf {
let nonce = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("system time")
.as_nanos();
let path = std::env::temp_dir().join(format!(
"criew-sendmail-{label}-{}-{nonce}",
std::process::id()
));
fs::create_dir_all(&path).expect("create temp dir");
path
}
fn canonicalize_existing_path(path: &Path) -> PathBuf {
fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
}
fn test_runtime_in(root: &Path) -> RuntimeConfig {
RuntimeConfig {
config_path: root.join("config.toml"),
data_dir: root.join("data"),
database_path: root.join("data/criew.db"),
raw_mail_dir: root.join("data/raw"),
patch_dir: root.join("data/patches"),
log_dir: root.join("data/logs"),
b4_path: None,
log_filter: "info".to_string(),
source_mailbox: "linux-kernel".to_string(),
imap: crate::infra::config::ImapConfig::default(),
lore_base_url: "https://lore.kernel.org".to_string(),
startup_sync: true,
ui_keymap: crate::infra::config::UiKeymap::Default,
ui_keymap_base: crate::infra::config::UiKeymapBase::Default,
ui_custom_keymap: crate::infra::config::UiCustomKeymapConfig::default(),
inbox_auto_sync_interval_secs:
crate::infra::config::DEFAULT_INBOX_AUTO_SYNC_INTERVAL_SECS,
kernel_trees: Vec::new(),
}
}
fn write_fake_git(root: &Path, body: &str) -> PathBuf {
let path = root.join("fake-git.sh");
let staging_path = root.join(".fake-git.sh.tmp");
let mut staging_file = fs::File::create(&staging_path).expect("create staging fake git");
staging_file
.write_all(body.as_bytes())
.expect("write staging fake git");
staging_file.sync_all().expect("sync staging fake git");
drop(staging_file);
let mut permissions = fs::metadata(&staging_path)
.expect("staging metadata")
.permissions();
permissions.set_mode(0o755);
fs::set_permissions(&staging_path, permissions).expect("chmod");
fs::rename(&staging_path, &path).expect("install fake git");
path
}
fn sample_request() -> SendRequest {
SendRequest {
mail_id: 3,
thread_id: 9,
from: "CRIEW Test <criew@example.com>".to_string(),
to: vec!["maintainer@example.com".to_string()],
cc: vec!["list@example.com".to_string()],
subject: "Re: [PATCH] demo".to_string(),
in_reply_to: "patch@example.com".to_string(),
references: vec!["patch@example.com".to_string()],
body: "reply body\n".to_string(),
preview_confirmed_at: "2026-03-07T10:00:00Z".to_string(),
}
}
#[test]
fn check_reports_available_send_email() {
let root = temp_dir("check-ok");
let fake_git = write_fake_git(
&root,
"#!/bin/sh\nif [ \"$1\" = \"--version\" ]; then\n echo 'git version 2.51.0'\n exit 0\nfi\nif [ \"$1\" = \"send-email\" ] && [ \"$2\" = \"-h\" ]; then\n echo 'usage: git send-email [<options>] <file|directory>...'\n exit 129\nfi\nexit 1\n",
);
let check = check_with_command_path(Some(&fake_git));
match check.status {
GitSendEmailStatus::Available { version, .. } => {
assert_eq!(version, "git version 2.51.0");
}
other => panic!("unexpected status: {other:?}"),
}
let _ = fs::remove_dir_all(root);
}
#[test]
fn check_accepts_single_line_send_email_help_banner() {
let root = temp_dir("check-help-banner");
let fake_git = write_fake_git(
&root,
"#!/bin/sh\nif [ \"$1\" = \"--version\" ]; then\n echo 'git version 2.51.0'\n exit 0\nfi\nif [ \"$1\" = \"send-email\" ] && [ \"$2\" = \"-h\" ]; then\n echo 'git send-email [<options>] <file|directory>'\n exit 129\nfi\nexit 1\n",
);
let check = check_with_command_path(Some(&fake_git));
match check.status {
GitSendEmailStatus::Available { version, .. } => {
assert_eq!(version, "git version 2.51.0");
}
other => panic!("unexpected status: {other:?}"),
}
let _ = fs::remove_dir_all(root);
}
#[test]
fn resolve_identity_prefers_sendemail_from() {
let root = temp_dir("identity");
let fake_git = write_fake_git(
&root,
"#!/bin/sh\nif [ \"$1\" = \"--version\" ]; then\n echo 'git version 2.51.0'\n exit 0\nfi\nif [ \"$1\" = \"config\" ] && [ \"$2\" = \"sendemail.from\" ]; then\n echo 'CRIEW Test <criew@example.com>'\n exit 0\nfi\nif [ \"$1\" = \"config\" ] && [ \"$2\" = \"user.email\" ]; then\n echo 'fallback@example.com'\n exit 0\nfi\nif [ \"$1\" = \"config\" ] && [ \"$2\" = \"user.name\" ]; then\n echo 'Fallback User'\n exit 0\nfi\nexit 1\n",
);
let identity = resolve_reply_identity_with_command_path(Some(&fake_git))
.expect("resolve reply identity");
assert_eq!(identity.display, "CRIEW Test <criew@example.com>");
assert_eq!(identity.email, "criew@example.com");
let _ = fs::remove_dir_all(root);
}
#[test]
fn check_reports_broken_and_missing_send_email() {
let root = temp_dir("check-broken");
let fake_git = write_fake_git(
&root,
"#!/bin/sh\nif [ \"$1\" = \"send-email\" ] && [ \"$2\" = \"-h\" ]; then\n echo 'fatal: send-email support missing' >&2\n exit 1\nfi\nexit 1\n",
);
match check_with_command_path(Some(&fake_git)).status {
GitSendEmailStatus::Broken { reason, .. } => {
assert_eq!(reason, "fatal: send-email support missing");
}
other => panic!("unexpected status: {other:?}"),
}
match check_with_command_path(Some(&root.join("missing-git"))).status {
GitSendEmailStatus::Missing => {}
other => panic!("unexpected status: {other:?}"),
}
let _ = fs::remove_dir_all(root);
}
#[test]
fn resolve_identity_falls_back_to_user_name_email_and_reports_errors() {
let root = temp_dir("identity-fallback");
let fallback_git = write_fake_git(
&root,
"#!/bin/sh\nif [ \"$1\" = \"--version\" ]; then\n echo 'git version 2.51.0'\n exit 0\nfi\nif [ \"$1\" = \"config\" ] && [ \"$2\" = \"sendemail.from\" ]; then\n exit 1\nfi\nif [ \"$1\" = \"config\" ] && [ \"$2\" = \"user.email\" ]; then\n echo 'fallback@example.com'\n exit 0\nfi\nif [ \"$1\" = \"config\" ] && [ \"$2\" = \"user.name\" ]; then\n echo ' '\n exit 0\nfi\nexit 1\n",
);
let identity = resolve_reply_identity_with_command_path(Some(&fallback_git))
.expect("resolve fallback identity");
assert_eq!(identity.display, "fallback@example.com");
assert_eq!(identity.email, "fallback@example.com");
assert_eq!(identity.source, ReplyIdentitySource::UserNameEmail);
assert_eq!(identity.source.as_str(), "git config user.name/user.email");
let invalid_git = write_fake_git(
&root,
"#!/bin/sh\nif [ \"$1\" = \"--version\" ]; then\n echo 'git version 2.51.0'\n exit 0\nfi\nif [ \"$1\" = \"config\" ] && [ \"$2\" = \"sendemail.from\" ]; then\n echo 'No Email Here'\n exit 0\nfi\nexit 1\n",
);
let invalid = resolve_reply_identity_with_command_path(Some(&invalid_git))
.expect_err("invalid sendemail.from should fail");
assert!(invalid.contains("does not contain a valid email address"));
let missing_git = write_fake_git(
&root,
"#!/bin/sh\nif [ \"$1\" = \"--version\" ]; then\n echo 'git version 2.51.0'\n exit 0\nfi\nif [ \"$1\" = \"config\" ] && [ \"$2\" = \"sendemail.from\" ]; then\n exit 1\nfi\nif [ \"$1\" = \"config\" ] && [ \"$2\" = \"user.email\" ]; then\n exit 1\nfi\nif [ \"$1\" = \"config\" ] && [ \"$2\" = \"user.name\" ]; then\n exit 1\nfi\nexit 1\n",
);
let missing = resolve_reply_identity_with_command_path(Some(&missing_git))
.expect_err("missing user email should fail");
assert!(missing.contains("git email identity missing"));
let _ = fs::remove_dir_all(root);
}
#[test]
fn send_success_removes_draft_and_keeps_generated_message_id() {
let root = temp_dir("send-success");
let capture = root.join("captured.eml");
let capture_args = root.join("captured-args.txt");
let fake_git = write_fake_git(
&root,
&format!(
"#!/bin/sh\nif [ \"$1\" = \"--version\" ]; then\n echo 'git version 2.51.0'\n exit 0\nfi\nif [ \"$1\" = \"send-email\" ] && [ \"$2\" = \"-h\" ]; then\n echo 'usage: git send-email [<options>] <file|directory>...'\n exit 129\nfi\nif [ \"$1\" = \"send-email\" ]; then\n printf '%s\\n' \"$@\" > '{}'\n last=''\n for arg in \"$@\"; do\n last=\"$arg\"\n done\n cp \"$last\" '{}'\n echo 'sent'\n exit 0\nfi\nexit 1\n",
capture_args.display(),
capture.display()
),
);
let runtime = test_runtime_in(&root);
let outcome = send_with_command_path(&runtime, &sample_request(), Some(&fake_git));
assert_eq!(outcome.status, SendStatus::Sent);
assert!(outcome.message_id.contains('@'));
assert!(outcome.draft_path.is_none());
let captured = fs::read_to_string(&capture).expect("read captured message");
assert!(captured.contains("From: CRIEW Test <criew@example.com>"));
assert!(captured.contains("To: maintainer@example.com"));
assert!(captured.contains("Cc: list@example.com"));
assert!(captured.contains("Subject: Re: [PATCH] demo"));
assert!(captured.contains("In-Reply-To: <patch@example.com>"));
assert!(captured.contains("References: <patch@example.com>"));
assert!(captured.contains("reply body"));
let captured_args = fs::read_to_string(&capture_args).expect("read captured args");
assert!(captured_args.contains("--confirm=never"));
assert!(captured_args.contains("--from"));
assert!(captured_args.contains("CRIEW Test <criew@example.com>"));
assert!(captured_args.contains("--to"));
assert!(captured_args.contains("maintainer@example.com"));
assert!(captured_args.contains("--cc"));
assert!(captured_args.contains("list@example.com"));
assert!(captured_args.contains("--subject"));
assert!(captured_args.contains("Re: [PATCH] demo"));
let _ = fs::remove_dir_all(root);
}
#[test]
fn send_failure_keeps_draft_and_summary() {
let root = temp_dir("send-fail");
let fake_git = write_fake_git(
&root,
"#!/bin/sh\nif [ \"$1\" = \"--version\" ]; then\n echo 'git version 2.51.0'\n exit 0\nfi\nif [ \"$1\" = \"send-email\" ] && [ \"$2\" = \"-h\" ]; then\n echo 'usage: git send-email [<options>] <file|directory>...'\n exit 129\nfi\nif [ \"$1\" = \"send-email\" ]; then\n echo 'smtp auth failed' >&2\n exit 1\nfi\nexit 1\n",
);
let runtime = test_runtime_in(&root);
let outcome = send_with_command_path(&runtime, &sample_request(), Some(&fake_git));
assert_eq!(outcome.status, SendStatus::Failed);
assert_eq!(outcome.error_summary.as_deref(), Some("smtp auth failed"));
assert!(
outcome
.draft_path
.as_ref()
.is_some_and(|path| path.exists())
);
let _ = fs::remove_dir_all(root);
}
#[test]
fn send_reports_unavailable_transport_without_creating_draft() {
let root = temp_dir("send-unavailable");
let fake_git = write_fake_git(
&root,
"#!/bin/sh\nif [ \"$1\" = \"send-email\" ] && [ \"$2\" = \"-h\" ]; then\n echo 'fatal: send-email support missing' >&2\n exit 1\nfi\nexit 1\n",
);
let runtime = test_runtime_in(&root);
let outcome = send_with_command_path(&runtime, &sample_request(), Some(&fake_git));
assert_eq!(outcome.status, SendStatus::Failed);
assert!(outcome.command_line.is_none());
assert!(outcome.draft_path.is_none());
assert!(
outcome
.error_summary
.as_deref()
.is_some_and(|summary| summary.contains("git send-email unavailable"))
);
let _ = fs::remove_dir_all(root);
}
#[test]
fn send_failure_uses_stdout_or_exit_code_when_stderr_is_empty() {
let root = temp_dir("send-summary-fallbacks");
let stdout_git = write_fake_git(
&root,
"#!/bin/sh\nif [ \"$1\" = \"--version\" ]; then\n echo 'git version 2.51.0'\n exit 0\nfi\nif [ \"$1\" = \"send-email\" ] && [ \"$2\" = \"-h\" ]; then\n echo 'usage: git send-email [<options>] <file|directory>...'\n exit 129\nfi\nif [ \"$1\" = \"send-email\" ]; then\n echo 'smtp failed on stdout'\n exit 2\nfi\nexit 1\n",
);
let runtime = test_runtime_in(&root);
let stdout_outcome = send_with_command_path(&runtime, &sample_request(), Some(&stdout_git));
assert_eq!(stdout_outcome.status, SendStatus::Failed);
assert_eq!(
stdout_outcome.error_summary.as_deref(),
Some("smtp failed on stdout")
);
let exit_code_git = write_fake_git(
&root,
"#!/bin/sh\nif [ \"$1\" = \"--version\" ]; then\n echo 'git version 2.51.0'\n exit 0\nfi\nif [ \"$1\" = \"send-email\" ] && [ \"$2\" = \"-h\" ]; then\n echo 'usage: git send-email [<options>] <file|directory>...'\n exit 129\nfi\nif [ \"$1\" = \"send-email\" ]; then\n exit 7\nfi\nexit 1\n",
);
let exit_code_outcome =
send_with_command_path(&runtime, &sample_request(), Some(&exit_code_git));
assert_eq!(exit_code_outcome.status, SendStatus::Failed);
assert_eq!(
exit_code_outcome.error_summary.as_deref(),
Some("git send-email exited with 7")
);
let _ = fs::remove_dir_all(root);
}
#[test]
fn send_times_out_and_keeps_draft_for_retry() {
let root = temp_dir("send-timeout");
let fake_git = write_fake_git(
&root,
"#!/bin/sh\nif [ \"$1\" = \"--version\" ]; then\n echo 'git version 2.51.0'\n exit 0\nfi\nif [ \"$1\" = \"send-email\" ] && [ \"$2\" = \"-h\" ]; then\n echo 'usage: git send-email [<options>] <file|directory>...'\n exit 129\nfi\nif [ \"$1\" = \"send-email\" ]; then\n sleep 1\n exit 0\nfi\nexit 1\n",
);
let runtime = test_runtime_in(&root);
let outcome = send_with_options(
&runtime,
&sample_request(),
Some(&fake_git),
std::time::Duration::from_millis(50),
);
assert_eq!(outcome.status, SendStatus::TimedOut);
assert!(outcome.timed_out);
assert!(
outcome
.draft_path
.as_ref()
.is_some_and(|path| path.exists())
);
assert_eq!(
outcome.error_summary.as_deref(),
Some("git send-email timed out after 0s")
);
let _ = fs::remove_dir_all(root);
}
#[test]
fn send_reports_outbox_creation_failure() {
let root = temp_dir("send-outbox-fail");
let data_file = root.join("blocked-data");
fs::write(&data_file, "not a directory").expect("write blocking data file");
let fake_git = write_fake_git(
&root,
"#!/bin/sh\nif [ \"$1\" = \"--version\" ]; then\n echo 'git version 2.51.0'\n exit 0\nfi\nif [ \"$1\" = \"send-email\" ] && [ \"$2\" = \"-h\" ]; then\n echo 'usage: git send-email [<options>] <file|directory>...'\n exit 129\nfi\nexit 1\n",
);
let mut runtime = test_runtime_in(&root);
runtime.data_dir = data_file;
let outcome = send_with_command_path(&runtime, &sample_request(), Some(&fake_git));
assert_eq!(outcome.status, SendStatus::Failed);
assert!(outcome.draft_path.is_none());
assert!(
outcome
.error_summary
.as_deref()
.is_some_and(|summary| summary.contains("failed to create reply outbox"))
);
let _ = fs::remove_dir_all(root);
}
#[test]
fn send_prefers_configured_kernel_tree_as_working_dir() {
let root = temp_dir("send-working-dir");
let kernel_tree = root.join("linux");
fs::create_dir_all(&kernel_tree).expect("create kernel tree");
let current_dir = std::env::current_dir().expect("current dir");
let capture_pwd = root.join("captured-pwd.txt");
let fake_git = write_fake_git(
&root,
&format!(
"#!/bin/sh\nif [ \"$1\" = \"--version\" ]; then\n echo 'git version 2.51.0'\n exit 0\nfi\nif [ \"$1\" = \"send-email\" ] && [ \"$2\" = \"-h\" ]; then\n echo 'usage: git send-email [<options>] <file|directory>...'\n exit 129\nfi\nif [ \"$1\" = \"send-email\" ]; then\n pwd > '{}'\n echo 'sent'\n exit 0\nfi\nexit 1\n",
capture_pwd.display()
),
);
let mut runtime = test_runtime_in(&root);
runtime.kernel_trees = vec![kernel_tree.clone()];
let outcome = send_with_command_path(&runtime, &sample_request(), Some(&fake_git));
assert_eq!(outcome.status, SendStatus::Sent);
let invoked_pwd = fs::read_to_string(&capture_pwd).expect("read captured pwd");
let invoked_working_dir = canonicalize_existing_path(Path::new(invoked_pwd.trim()));
assert_eq!(
invoked_working_dir,
canonicalize_existing_path(&kernel_tree)
);
assert_ne!(
invoked_working_dir,
canonicalize_existing_path(¤t_dir)
);
let _ = fs::remove_dir_all(root);
}
#[test]
fn send_passes_absolute_draft_path_when_runtime_data_dir_is_relative() {
let root = temp_dir("send-relative-data-dir");
let kernel_tree = root.join("linux");
fs::create_dir_all(&kernel_tree).expect("create kernel tree");
let capture_arg = root.join("captured-draft-arg.txt");
let fake_git = write_fake_git(
&root,
&format!(
"#!/bin/sh\nif [ \"$1\" = \"--version\" ]; then\n echo 'git version 2.51.0'\n exit 0\nfi\nif [ \"$1\" = \"send-email\" ] && [ \"$2\" = \"-h\" ]; then\n echo 'usage: git send-email [<options>] <file|directory>...'\n exit 129\nfi\nif [ \"$1\" = \"send-email\" ]; then\n last=''\n for arg in \"$@\"; do\n last=\"$arg\"\n done\n printf '%s' \"$last\" > '{}'\n if [ -f \"$last\" ]; then\n echo 'sent'\n exit 0\n fi\n echo \"missing draft: $last\" >&2\n exit 1\nfi\nexit 1\n",
capture_arg.display()
),
);
let nonce = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("system time")
.as_nanos();
let relative_data_dir = PathBuf::from(format!(
"target/test-relative-reply-outbox-{}-{nonce}",
std::process::id()
));
let relative_data_dir_absolute = std::env::current_dir()
.expect("current dir")
.join(&relative_data_dir);
let mut runtime = test_runtime_in(&root);
runtime.data_dir = relative_data_dir;
runtime.kernel_trees = vec![kernel_tree];
let outcome = send_with_command_path(&runtime, &sample_request(), Some(&fake_git));
assert_eq!(outcome.status, SendStatus::Sent);
let captured_arg = fs::read_to_string(&capture_arg).expect("read captured draft arg");
assert!(Path::new(&captured_arg).is_absolute());
assert!(captured_arg.starts_with(relative_data_dir_absolute.to_string_lossy().as_ref()));
let _ = fs::remove_dir_all(relative_data_dir_absolute);
let _ = fs::remove_dir_all(root);
}
#[test]
fn message_rendering_and_helper_outputs_follow_reply_contract() {
let mut request = sample_request();
request.cc.clear();
request.references = vec![
"<older@example.com>,".to_string(),
"patch@example.com".to_string(),
];
let rendered = render_message_file(&request, "generated@example.com");
assert!(rendered.contains("From: CRIEW Test <criew@example.com>"));
assert!(rendered.contains("To: maintainer@example.com"));
assert!(!rendered.contains("\nCc: "));
assert!(rendered.contains("Message-ID: <generated@example.com>"));
assert!(rendered.contains("In-Reply-To: <patch@example.com>"));
assert!(rendered.contains("References: <older@example.com> <patch@example.com>"));
assert!(rendered.ends_with("reply body\n"));
assert_eq!(
render_command_line(
"git send-email",
&[
"--from".to_string(),
"CRIEW Test <criew@example.com>".to_string(),
String::new(),
]
),
"'git send-email' --from 'CRIEW Test <criew@example.com>' ''"
);
assert_eq!(
normalize_output(b"\n first line \nsecond\n"),
Some("first line".to_string())
);
assert_eq!(
summarize_failure(Some(9), "", ""),
Some("git send-email exited with 9".to_string())
);
assert_eq!(
normalize_message_id(" <older@example.com>, "),
"older@example.com"
);
assert_eq!(
extract_email_address("CRIEW Test <criew@example.com>"),
Some("criew@example.com".to_string())
);
assert_eq!(extract_email_address("invalid identity"), None);
assert!(generate_message_id("No Email").ends_with("@localhost"));
assert!(stabilize_child_path(Path::new("relative/file.eml")).is_absolute());
}
#[test]
fn resolve_working_dir_falls_back_when_kernel_tree_is_missing() {
let root = temp_dir("send-working-dir-fallback");
let runtime = test_runtime_in(&root);
let current_dir = std::env::current_dir().expect("current dir");
assert_eq!(resolve_working_dir(&runtime), current_dir);
let mut with_invalid_tree = test_runtime_in(&root);
with_invalid_tree.kernel_trees = vec![root.join("missing-tree")];
assert_eq!(resolve_working_dir(&with_invalid_tree), current_dir);
let _ = fs::remove_dir_all(root);
}
}