v0.2.22
RustBox CI / rustbox (push) Canceled after 0s

This commit is contained in:
Mateusz Gruszczyński committed 2026-11-04 14:59:03 +01:00
1 parent 6e74b33f03
commit 3b9110c8c8
44 files changed
+3057 -128

No files matched your search

+194
View File
@@ -0,0 +1,194 @@
//! In-memory staging for "Undo Send".
//!
//! When `undo_send_window_secs > 0`, a fully built outgoing message is held
//! in memory for that many seconds before it is actually dispatched to SMTP,
//! so the sender can cancel it. Nothing here is persisted: a server restart
//! during the window loses any message still staged, the same way a message
//! already queued by any mail server can be lost if the process is killed
//! mid-flight. The window is meant to be short (seconds, not minutes) for
//! exactly this reason.
//!
//! State transitions are `Pending -> Cancelled` (a user clicks "Undo") or
//! `Pending -> Dispatching -> Sent | Failed` (the window elapses). Both the
//! cancel handler and the delayed dispatch task race to leave `Pending`, but
//! only one of them can win because the transition happens under a single
//! mutex: whichever reaches it first decides the outcome.
use std::collections::HashMap;
use std::sync::Mutex;
use std::time::{SystemTime, UNIX_EPOCH};
/// How long a finished (sent/cancelled/failed) entry is kept around so a late
/// status poll from the client still finds it.
const RESULT_RETENTION_SECS: u64 = 120;
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)]
#[serde(tag = "status", rename_all = "snake_case")]
pub enum PendingOutcome {
Pending,
Dispatching,
Sent { message_id: String },
Cancelled,
Failed { error: String },
}
struct PendingEntry {
outcome: PendingOutcome,
recorded_at: u64,
}
fn now_epoch() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
}
#[derive(Default)]
pub struct UndoSendRegistry {
entries: Mutex<HashMap<String, PendingEntry>>,
}
impl UndoSendRegistry {
pub fn new() -> Self {
Self::default()
}
fn sweep(entries: &mut HashMap<String, PendingEntry>) {
let now = now_epoch();
entries.retain(|_, entry| {
matches!(
entry.outcome,
PendingOutcome::Pending | PendingOutcome::Dispatching
) || now.saturating_sub(entry.recorded_at) < RESULT_RETENTION_SECS
});
}
/// Registers a new pending send and returns its id.
pub fn stage(&self) -> String {
let id = uuid::Uuid::new_v4().to_string();
let mut entries = self.entries.lock().expect("undo-send registry lock");
Self::sweep(&mut entries);
entries.insert(
id.clone(),
PendingEntry {
outcome: PendingOutcome::Pending,
recorded_at: now_epoch(),
},
);
id
}
/// Cancels a still-pending send. Returns `true` if this call is the one
/// that cancelled it; `false` if it was already dispatching/finished or
/// the id is unknown/expired.
pub fn cancel(&self, id: &str) -> bool {
let mut entries = self.entries.lock().expect("undo-send registry lock");
match entries.get_mut(id) {
Some(entry) if entry.outcome == PendingOutcome::Pending => {
entry.outcome = PendingOutcome::Cancelled;
entry.recorded_at = now_epoch();
true
}
_ => false,
}
}
/// Called by the delayed dispatch task once its wait is over, right
/// before it would contact SMTP. Returns `true` if this call is the one
/// that claimed the send (i.e. it was not cancelled in the meantime);
/// the caller must then actually dispatch and record the result.
pub fn begin_dispatch(&self, id: &str) -> bool {
let mut entries = self.entries.lock().expect("undo-send registry lock");
match entries.get_mut(id) {
Some(entry) if entry.outcome == PendingOutcome::Pending => {
entry.outcome = PendingOutcome::Dispatching;
true
}
_ => false,
}
}
pub fn record_sent(&self, id: &str, message_id: String) {
let mut entries = self.entries.lock().expect("undo-send registry lock");
if let Some(entry) = entries.get_mut(id) {
entry.outcome = PendingOutcome::Sent { message_id };
entry.recorded_at = now_epoch();
}
}
pub fn record_failed(&self, id: &str, error: String) {
let mut entries = self.entries.lock().expect("undo-send registry lock");
if let Some(entry) = entries.get_mut(id) {
entry.outcome = PendingOutcome::Failed { error };
entry.recorded_at = now_epoch();
}
}
pub fn status(&self, id: &str) -> Option<PendingOutcome> {
let mut entries = self.entries.lock().expect("undo-send registry lock");
Self::sweep(&mut entries);
entries.get(id).map(|entry| entry.outcome.clone())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_pending_send_can_be_cancelled_exactly_once() {
let registry = UndoSendRegistry::new();
let id = registry.stage();
assert_eq!(registry.status(&id), Some(PendingOutcome::Pending));
assert!(registry.cancel(&id));
assert_eq!(registry.status(&id), Some(PendingOutcome::Cancelled));
// A second cancel is a no-op: the first one already won the race.
assert!(!registry.cancel(&id));
}
#[test]
fn dispatch_cannot_start_once_cancelled() {
let registry = UndoSendRegistry::new();
let id = registry.stage();
assert!(registry.cancel(&id));
assert!(!registry.begin_dispatch(&id));
}
#[test]
fn cancel_cannot_win_once_dispatch_has_begun() {
let registry = UndoSendRegistry::new();
let id = registry.stage();
assert!(registry.begin_dispatch(&id));
assert!(!registry.cancel(&id));
registry.record_sent(&id, "msg-1".to_string());
assert_eq!(
registry.status(&id),
Some(PendingOutcome::Sent {
message_id: "msg-1".to_string()
})
);
}
#[test]
fn a_dispatch_that_fails_is_recorded_as_failed() {
let registry = UndoSendRegistry::new();
let id = registry.stage();
assert!(registry.begin_dispatch(&id));
registry.record_failed(&id, "SMTP timed out".to_string());
assert_eq!(
registry.status(&id),
Some(PendingOutcome::Failed {
error: "SMTP timed out".to_string()
})
);
}
#[test]
fn an_unknown_id_has_no_status() {
let registry = UndoSendRegistry::new();
assert_eq!(registry.status("does-not-exist"), None);
assert!(!registry.cancel("does-not-exist"));
assert!(!registry.begin_dispatch("does-not-exist"));
}
}