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}