Skip to main content

rusmes_server/
session_logging.rs

1//! Per-session structured logging
2//!
3//! This module provides session-aware structured logging for all protocol servers
4//! (SMTP, IMAP, POP3, JMAP). Each connection gets a unique session ID that is
5//! attached to all log events, making it easy to trace and debug individual sessions.
6//!
7//! # Features
8//!
9//! - Unique session IDs (UUID v4)
10//! - Session context with client IP, protocol, and user information
11//! - Integration with tracing-subscriber for structured logging
12//! - Helper macros for convenient session-aware logging
13//! - Automatic span management per session
14//!
15//! # Example
16//!
17//! ```no_run
18//! use rusmes_server::session_logging::{SessionContext, SessionLogger};
19//! use std::net::IpAddr;
20//!
21//! # async fn example() {
22//! let session = SessionContext::new(
23//!     IpAddr::from([127, 0, 0, 1]),
24//!     "SMTP",
25//! );
26//!
27//! let logger = SessionLogger::new(session);
28//! let _guard = logger.enter();
29//!
30//! // All logs within this span will include session context
31//! tracing::info!("Connection established");
32//! # }
33//! ```
34
35use std::net::IpAddr;
36use tracing::{span, Level, Span};
37use uuid::Uuid;
38
39/// Session context for structured logging
40///
41/// Contains all session-level information that should be attached to log events:
42/// - Unique session ID
43/// - Client IP address
44/// - Protocol name (SMTP, IMAP, POP3, JMAP)
45/// - Optional authenticated username
46#[derive(Debug, Clone)]
47pub struct SessionContext {
48    /// Unique session identifier (UUID v4)
49    pub session_id: String,
50
51    /// Client IP address
52    pub client_ip: IpAddr,
53
54    /// Protocol name (e.g., "SMTP", "IMAP", "POP3", "JMAP")
55    pub protocol: String,
56
57    /// Authenticated username (if any)
58    pub username: Option<String>,
59}
60
61impl SessionContext {
62    /// Create a new session context with a generated UUID
63    ///
64    /// # Arguments
65    ///
66    /// * `client_ip` - The client's IP address
67    /// * `protocol` - The protocol name (e.g., "SMTP", "IMAP")
68    ///
69    /// # Example
70    ///
71    /// ```no_run
72    /// use rusmes_server::session_logging::SessionContext;
73    /// use std::net::IpAddr;
74    ///
75    /// let session = SessionContext::new(
76    ///     IpAddr::from([192, 168, 1, 100]),
77    ///     "SMTP",
78    /// );
79    /// ```
80    pub fn new(client_ip: IpAddr, protocol: impl Into<String>) -> Self {
81        Self {
82            session_id: Uuid::new_v4().to_string(),
83            client_ip,
84            protocol: protocol.into(),
85            username: None,
86        }
87    }
88
89    /// Create a new session context with a custom session ID
90    ///
91    /// Useful for testing or when you need a specific ID format.
92    ///
93    /// # Arguments
94    ///
95    /// * `session_id` - Custom session ID
96    /// * `client_ip` - The client's IP address
97    /// * `protocol` - The protocol name
98    pub fn with_id(
99        session_id: impl Into<String>,
100        client_ip: IpAddr,
101        protocol: impl Into<String>,
102    ) -> Self {
103        Self {
104            session_id: session_id.into(),
105            client_ip,
106            protocol: protocol.into(),
107            username: None,
108        }
109    }
110
111    /// Set the authenticated username
112    ///
113    /// Call this after successful authentication to include the username
114    /// in all subsequent log events.
115    ///
116    /// # Arguments
117    ///
118    /// * `username` - The authenticated username
119    pub fn set_username(&mut self, username: impl Into<String>) {
120        self.username = Some(username.into());
121    }
122
123    /// Get the session ID
124    pub fn session_id(&self) -> &str {
125        &self.session_id
126    }
127
128    /// Get the client IP
129    pub fn client_ip(&self) -> IpAddr {
130        self.client_ip
131    }
132
133    /// Get the protocol name
134    pub fn protocol(&self) -> &str {
135        &self.protocol
136    }
137
138    /// Get the username if authenticated
139    pub fn username(&self) -> Option<&str> {
140        self.username.as_deref()
141    }
142}
143
144/// Session-aware logger that wraps tracing spans
145///
146/// Creates a tracing span with session context fields and provides
147/// convenient methods for logging with session information.
148#[derive(Debug)]
149pub struct SessionLogger {
150    /// Session context
151    context: SessionContext,
152
153    /// Tracing span for this session
154    span: Span,
155}
156
157impl SessionLogger {
158    /// Create a new session logger
159    ///
160    /// This creates a tracing span at INFO level with all session context fields.
161    ///
162    /// # Arguments
163    ///
164    /// * `context` - The session context
165    ///
166    /// # Example
167    ///
168    /// ```no_run
169    /// use rusmes_server::session_logging::{SessionContext, SessionLogger};
170    /// use std::net::IpAddr;
171    ///
172    /// let session = SessionContext::new(
173    ///     IpAddr::from([127, 0, 0, 1]),
174    ///     "IMAP",
175    /// );
176    /// let logger = SessionLogger::new(session);
177    /// ```
178    pub fn new(context: SessionContext) -> Self {
179        let span = if let Some(ref username) = context.username {
180            span!(
181                Level::INFO,
182                "session",
183                session_id = %context.session_id,
184                client_ip = %context.client_ip,
185                protocol = %context.protocol,
186                username = %username,
187            )
188        } else {
189            span!(
190                Level::INFO,
191                "session",
192                session_id = %context.session_id,
193                client_ip = %context.client_ip,
194                protocol = %context.protocol,
195            )
196        };
197
198        Self { context, span }
199    }
200
201    /// Enter the session span
202    ///
203    /// Returns a guard that will exit the span when dropped.
204    /// All logging done while the guard is held will include session context.
205    ///
206    /// # Example
207    ///
208    /// ```no_run
209    /// use rusmes_server::session_logging::{SessionContext, SessionLogger};
210    /// use std::net::IpAddr;
211    ///
212    /// # async fn example() {
213    /// let session = SessionContext::new(
214    ///     IpAddr::from([127, 0, 0, 1]),
215    ///     "POP3",
216    /// );
217    /// let logger = SessionLogger::new(session);
218    /// let _guard = logger.enter();
219    ///
220    /// tracing::info!("This log will include session context");
221    /// # }
222    /// ```
223    pub fn enter(&self) -> tracing::span::Entered<'_> {
224        self.span.enter()
225    }
226
227    /// Get the session context
228    pub fn context(&self) -> &SessionContext {
229        &self.context
230    }
231
232    /// Update the session context with a username
233    ///
234    /// This creates a new span with the updated username field.
235    ///
236    /// # Arguments
237    ///
238    /// * `username` - The authenticated username
239    pub fn set_username(&mut self, username: impl Into<String>) {
240        self.context.set_username(username);
241
242        // Create a new span with the username
243        let username_str = self.context.username.as_deref().unwrap_or("<unknown>");
244        self.span = span!(
245            Level::INFO,
246            "session",
247            session_id = %self.context.session_id,
248            client_ip = %self.context.client_ip,
249            protocol = %self.context.protocol,
250            username = %username_str,
251        );
252    }
253
254    /// Get the tracing span
255    pub fn span(&self) -> &Span {
256        &self.span
257    }
258}
259
260/// Helper macros for session-aware logging
261///
262/// These macros make it convenient to log with session context
263/// without having to manually specify fields each time.
264/// Log an info message with session context
265///
266/// # Example
267///
268/// ```ignore
269/// session_info!(logger, "Command received", command = "HELO");
270/// ```
271#[macro_export]
272macro_rules! session_info {
273    ($logger:expr, $($arg:tt)*) => {
274        {
275            let _guard = $logger.enter();
276            tracing::info!($($arg)*);
277        }
278    };
279}
280
281/// Log a debug message with session context
282///
283/// # Example
284///
285/// ```ignore
286/// session_debug!(logger, "Parsing command", input = &line);
287/// ```
288#[macro_export]
289macro_rules! session_debug {
290    ($logger:expr, $($arg:tt)*) => {
291        {
292            let _guard = $logger.enter();
293            tracing::debug!($($arg)*);
294        }
295    };
296}
297
298/// Log a warning message with session context
299///
300/// # Example
301///
302/// ```ignore
303/// session_warn!(logger, "Rate limit approaching", remaining = 10);
304/// ```
305#[macro_export]
306macro_rules! session_warn {
307    ($logger:expr, $($arg:tt)*) => {
308        {
309            let _guard = $logger.enter();
310            tracing::warn!($($arg)*);
311        }
312    };
313}
314
315/// Log an error message with session context
316///
317/// # Example
318///
319/// ```ignore
320/// session_error!(logger, "Authentication failed", reason = "invalid_password");
321/// ```
322#[macro_export]
323macro_rules! session_error {
324    ($logger:expr, $($arg:tt)*) => {
325        {
326            let _guard = $logger.enter();
327            tracing::error!($($arg)*);
328        }
329    };
330}
331
332/// Log a trace message with session context
333///
334/// # Example
335///
336/// ```ignore
337/// session_trace!(logger, "State transition", from = "CONNECTED", to = "AUTHENTICATED");
338/// ```
339#[macro_export]
340macro_rules! session_trace {
341    ($logger:expr, $($arg:tt)*) => {
342        {
343            let _guard = $logger.enter();
344            tracing::trace!($($arg)*);
345        }
346    };
347}
348
349/// Helper function to format session context for response headers
350///
351/// This is primarily useful for JMAP and other HTTP-based protocols
352/// where you can include session IDs in response headers.
353///
354/// # Arguments
355///
356/// * `context` - The session context
357///
358/// # Returns
359///
360/// A formatted string suitable for use in a response header (just the session ID)
361///
362/// # Example
363///
364/// ```no_run
365/// use rusmes_server::session_logging::{SessionContext, format_session_header};
366/// use std::net::IpAddr;
367///
368/// let session = SessionContext::new(
369///     IpAddr::from([127, 0, 0, 1]),
370///     "JMAP",
371/// );
372/// let header_value = format_session_header(&session);
373/// // Use in HTTP response: X-Session-Id: <uuid>
374/// ```
375pub fn format_session_header(context: &SessionContext) -> String {
376    context.session_id.clone()
377}
378
379/// Create a JSON representation of session context
380///
381/// Useful for structured log outputs or external monitoring systems.
382///
383/// # Arguments
384///
385/// * `context` - The session context
386///
387/// # Returns
388///
389/// A JSON string with session information
390pub fn format_session_json(context: &SessionContext) -> String {
391    let username_str = context.username.as_deref().unwrap_or("");
392    format!(
393        r#"{{"session_id":"{}","client_ip":"{}","protocol":"{}","username":"{}"}}"#,
394        context.session_id, context.client_ip, context.protocol, username_str
395    )
396}
397
398#[cfg(test)]
399mod tests {
400    use super::*;
401    use std::net::IpAddr;
402
403    #[test]
404    fn test_session_context_creation() {
405        let ip = IpAddr::from([192, 168, 1, 100]);
406        let session = SessionContext::new(ip, "SMTP");
407
408        assert!(!session.session_id.is_empty());
409        assert_eq!(session.client_ip, ip);
410        assert_eq!(session.protocol, "SMTP");
411        assert!(session.username.is_none());
412    }
413
414    #[test]
415    fn test_session_context_with_id() {
416        let ip = IpAddr::from([10, 0, 0, 1]);
417        let custom_id = "test-session-123";
418        let session = SessionContext::with_id(custom_id, ip, "IMAP");
419
420        assert_eq!(session.session_id, custom_id);
421        assert_eq!(session.client_ip, ip);
422        assert_eq!(session.protocol, "IMAP");
423    }
424
425    #[test]
426    fn test_set_username() {
427        let ip = IpAddr::from([127, 0, 0, 1]);
428        let mut session = SessionContext::new(ip, "POP3");
429
430        assert!(session.username.is_none());
431
432        session.set_username("alice");
433        assert_eq!(session.username.as_deref(), Some("alice"));
434    }
435
436    #[test]
437    fn test_session_logger_creation() {
438        let ip = IpAddr::from([192, 168, 1, 1]);
439        let context = SessionContext::new(ip, "JMAP");
440        let logger = SessionLogger::new(context);
441
442        assert_eq!(logger.context().protocol, "JMAP");
443        assert_eq!(logger.context().client_ip, ip);
444    }
445
446    #[test]
447    fn test_session_logger_set_username() {
448        let ip = IpAddr::from([172, 16, 0, 1]);
449        let context = SessionContext::new(ip, "SMTP");
450        let mut logger = SessionLogger::new(context);
451
452        assert!(logger.context().username.is_none());
453
454        logger.set_username("bob");
455        assert_eq!(logger.context().username.as_deref(), Some("bob"));
456    }
457
458    #[test]
459    fn test_format_session_header() {
460        let ip = IpAddr::from([127, 0, 0, 1]);
461        let session = SessionContext::with_id("abc-123", ip, "JMAP");
462        let header = format_session_header(&session);
463
464        assert_eq!(header, "abc-123");
465    }
466
467    #[test]
468    fn test_format_session_json() {
469        let ip = IpAddr::from([192, 168, 1, 50]);
470        let mut session = SessionContext::with_id("test-id", ip, "IMAP");
471        session.set_username("alice");
472
473        let json = format_session_json(&session);
474        assert!(json.contains(r#""session_id":"test-id""#));
475        assert!(json.contains(r#""client_ip":"192.168.1.50""#));
476        assert!(json.contains(r#""protocol":"IMAP""#));
477        assert!(json.contains(r#""username":"alice""#));
478    }
479
480    #[test]
481    fn test_format_session_json_no_username() {
482        let ip = IpAddr::from([10, 0, 0, 1]);
483        let session = SessionContext::with_id("session-123", ip, "SMTP");
484
485        let json = format_session_json(&session);
486        assert!(json.contains(r#""username":"""#));
487    }
488
489    #[test]
490    fn test_session_id_is_uuid() {
491        let ip = IpAddr::from([127, 0, 0, 1]);
492        let session = SessionContext::new(ip, "POP3");
493
494        // Try to parse as UUID to verify format
495        let parsed = Uuid::parse_str(&session.session_id);
496        assert!(parsed.is_ok(), "Session ID should be a valid UUID");
497    }
498
499    #[test]
500    fn test_unique_session_ids() {
501        let ip = IpAddr::from([127, 0, 0, 1]);
502        let session1 = SessionContext::new(ip, "SMTP");
503        let session2 = SessionContext::new(ip, "SMTP");
504
505        assert_ne!(
506            session1.session_id, session2.session_id,
507            "Session IDs should be unique"
508        );
509    }
510}