Skip to main content

postcrate_core/
events.rs

1//! UI-agnostic event emission. Consumers — desktop shells, CLI tail
2//! commands, integration tests — implement [`EventSink`] and pass it
3//! to [`crate::Service::build`]. The engine never touches a UI
4//! framework directly.
5
6use std::sync::Arc;
7
8use serde::Serialize;
9use tokio::sync::broadcast;
10
11use crate::db::audit::AuditEntry;
12use crate::db::emails::EmailSummary;
13use crate::db::settings::SettingsSection;
14
15/// Implementors receive all engine-level events.
16pub trait EventSink: Send + Sync + 'static {
17    fn emit(&self, event: CoreEvent);
18}
19
20/// Engine event surface. Adding a variant is a semver-minor change —
21/// consumers using `Sink: EventSink` only have to handle what they know.
22#[derive(Debug, Clone, Serialize)]
23#[cfg_attr(feature = "specta", derive(specta::Type))]
24#[serde(tag = "kind", rename_all = "camelCase", rename_all_fields = "camelCase")]
25pub enum CoreEvent {
26    NewEmail {
27        mailbox_id: String,
28        email: EmailSummary,
29    },
30    MailboxStateChanged {
31        mailbox_id: String,
32        change: MailboxStateChange,
33    },
34    ServerStatusChanged {
35        status: ServerStatus,
36    },
37    SettingsChanged {
38        section: SettingsSection,
39    },
40    AuditAppended {
41        entry: AuditEntry,
42    },
43}
44
45#[derive(Debug, Clone, Serialize)]
46#[cfg_attr(feature = "specta", derive(specta::Type))]
47#[serde(tag = "kind", rename_all = "camelCase", rename_all_fields = "camelCase")]
48pub enum MailboxStateChange {
49    Created,
50    Updated,
51    Deleted,
52    Started,
53    Stopped,
54    Expired,
55    Failed { error: String },
56}
57
58#[derive(Debug, Clone, Serialize)]
59#[cfg_attr(feature = "specta", derive(specta::Type))]
60#[serde(rename_all = "camelCase")]
61pub struct ServerStatus {
62    pub running_mailboxes: u32,
63    pub http_running: bool,
64    pub errors: Vec<String>,
65}
66
67#[derive(Debug, Clone, Copy, Serialize)]
68#[cfg_attr(feature = "specta", derive(specta::Type))]
69#[serde(rename_all = "lowercase")]
70pub enum BounceKind {
71    Hard,
72    Soft,
73}
74
75impl BounceKind {
76    pub fn as_str(self) -> &'static str {
77        match self {
78            BounceKind::Hard => "hard",
79            BounceKind::Soft => "soft",
80        }
81    }
82    pub fn from_str(s: &str) -> Self {
83        if s.eq_ignore_ascii_case("soft") {
84            BounceKind::Soft
85        } else {
86            BounceKind::Hard
87        }
88    }
89}
90
91// We can't derive Deserialize for BounceKind in this position because
92// the Serialize block above already lives in `events.rs`. Instead, do
93// it explicitly so the wire format (`"hard"` / `"soft"`) is preserved.
94impl<'de> serde::Deserialize<'de> for BounceKind {
95    fn deserialize<D>(de: D) -> std::result::Result<Self, D::Error>
96    where
97        D: serde::Deserializer<'de>,
98    {
99        let s = String::deserialize(de)?;
100        Ok(Self::from_str(&s))
101    }
102}
103
104// ---- built-in sinks -------------------------------------------------
105
106/// Trace each event through `tracing::info!`. Handy default for the
107/// headless `postcrate` binary and for tests that just want logs.
108#[derive(Debug, Default, Clone, Copy)]
109pub struct LogSink;
110
111impl EventSink for LogSink {
112    fn emit(&self, event: CoreEvent) {
113        tracing::info!(target: "postcrate::event", event = ?event);
114    }
115}
116
117/// Fan out via a `tokio::sync::broadcast` so multiple subscribers
118/// (CLI `tail`, tests) can observe the same stream.
119#[derive(Debug, Clone)]
120pub struct ChannelSink {
121    tx: broadcast::Sender<CoreEvent>,
122}
123
124impl ChannelSink {
125    pub fn new(capacity: usize) -> Self {
126        Self {
127            tx: broadcast::channel(capacity).0,
128        }
129    }
130    pub fn subscribe(&self) -> broadcast::Receiver<CoreEvent> {
131        self.tx.subscribe()
132    }
133}
134
135impl EventSink for ChannelSink {
136    fn emit(&self, event: CoreEvent) {
137        let _ = self.tx.send(event);
138    }
139}
140
141/// Trivial fan-out wrapper so an embedder can plug in more than one
142/// sink without writing a custom struct.
143#[derive(Clone)]
144pub struct ComposedSink {
145    sinks: Vec<Arc<dyn EventSink>>,
146}
147
148impl std::fmt::Debug for ComposedSink {
149    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
150        f.debug_struct("ComposedSink")
151            .field("len", &self.sinks.len())
152            .finish()
153    }
154}
155
156impl ComposedSink {
157    pub fn new(sinks: Vec<Arc<dyn EventSink>>) -> Self {
158        Self { sinks }
159    }
160}
161
162impl EventSink for ComposedSink {
163    fn emit(&self, event: CoreEvent) {
164        for s in &self.sinks {
165            s.emit(event.clone());
166        }
167    }
168}