Skip to main content

cot/email/transport/
console.rs

1//! Console transport implementation.
2//!
3//! This backend writes a human-friendly representation of emails to stdout.
4//! It is intended primarily for development and testing environments where
5//! actually sending email is not required.
6//!
7//! Typical usage is through the high-level [`crate::email::Email`] API.
8//!
9//! ## Examples
10//!
11//! ```
12//! use cot::common_types::Email;
13//! use cot::email::EmailMessage;
14//! use cot::email::transport::console::Console;
15//!
16//! # #[tokio::main]
17//! # async fn main() -> cot::Result<()> {
18//! let email = cot::email::Email::new(Console::new());
19//! let recipients = vec![Email::try_from("testrecipient@example.com").unwrap()];
20//! let msg = EmailMessage::builder()
21//!     .from(Email::try_from("no-reply@example.com").unwrap())
22//!     .to(vec![Email::try_from("user@example.com").unwrap()])
23//!     .build()?;
24//! email.send(msg).await?;
25//! # Ok(()) }
26//! ```
27use std::io::Write;
28use std::{fmt, io};
29
30use cot::email::EmailMessage;
31use cot::email::transport::TransportError;
32use thiserror::Error;
33
34use crate::email::transport::{Transport, TransportResult};
35
36const ERROR_PREFIX: &str = "console transport error:";
37
38/// Errors that can occur while using the console transport.
39#[derive(Debug, Error)]
40#[non_exhaustive]
41pub enum ConsoleError {
42    /// An IO error occurred while writing to stdout.
43    #[error("{ERROR_PREFIX} IO error: {0}")]
44    Io(#[from] io::Error),
45}
46
47impl From<ConsoleError> for TransportError {
48    fn from(err: ConsoleError) -> Self {
49        TransportError::Backend(Box::new(err))
50    }
51}
52
53/// A transport backend that prints emails to stdout.
54///
55/// # Examples
56///
57/// ```
58/// use cot::email::transport::console::Console;
59///
60/// let console_transport = Console::new();
61/// ```
62#[derive(Debug, Clone, Copy)]
63#[non_exhaustive]
64pub struct Console;
65
66impl Console {
67    /// Create a new console transport backend.
68    ///
69    /// # Examples
70    ///
71    /// ```
72    /// use cot::email::transport::console::Console;
73    ///
74    /// let console_transport = Console::new();
75    /// ```
76    #[must_use]
77    pub fn new() -> Self {
78        Self {}
79    }
80}
81
82impl Default for Console {
83    fn default() -> Self {
84        Self::new()
85    }
86}
87
88impl Transport for Console {
89    async fn send(&self, messages: &[EmailMessage]) -> TransportResult<()> {
90        let mut out = io::stdout().lock();
91        for msg in messages {
92            writeln!(out, "{msg}").map_err(ConsoleError::Io)?;
93            writeln!(out, "{}", "─".repeat(60)).map_err(ConsoleError::Io)?;
94        }
95        Ok(())
96    }
97}
98
99impl fmt::Display for EmailMessage {
100    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
101        let fmt_list = |list: &Vec<crate::common_types::Email>| -> String {
102            if list.is_empty() {
103                "-".to_string()
104            } else {
105                list.iter()
106                    .map(|a| a.email().clone())
107                    .collect::<Vec<_>>()
108                    .join(", ")
109            }
110        };
111
112        writeln!(
113            f,
114            "════════════════════════════════════════════════════════════════"
115        )?;
116        writeln!(f, "From    : {}", self.from.email())?;
117        writeln!(f, "To      : {}", fmt_list(&self.to))?;
118        if !self.cc.is_empty() {
119            writeln!(f, "Cc      : {}", fmt_list(&self.cc))?;
120        }
121        if !self.bcc.is_empty() {
122            writeln!(f, "Bcc     : {}", fmt_list(&self.bcc))?;
123        }
124        if !self.reply_to.is_empty() {
125            writeln!(f, "Reply-To: {}", fmt_list(&self.reply_to))?;
126        }
127        writeln!(
128            f,
129            "Subject : {}",
130            if self.subject.is_empty() {
131                "-"
132            } else {
133                &self.subject
134            }
135        )?;
136        writeln!(
137            f,
138            "────────────────────────────────────────────────────────"
139        )?;
140        if self.body.trim().is_empty() {
141            writeln!(f, "<empty>")?;
142        } else {
143            writeln!(f, "{}", self.body.trim_end())?;
144        }
145        writeln!(
146            f,
147            "────────────────────────────────────────────────────────"
148        )?;
149        if self.attachments.is_empty() {
150            writeln!(f, "Attachments: -")?;
151        } else {
152            writeln!(f, "Attachments ({}):", self.attachments.len())?;
153            for a in &self.attachments {
154                writeln!(
155                    f,
156                    "  - {} ({} bytes, {})",
157                    a.filename,
158                    a.data.len(),
159                    a.content_type
160                )?;
161            }
162        }
163        writeln!(
164            f,
165            "════════════════════════════════════════════════════════════════"
166        )?;
167        Ok(())
168    }
169}
170
171#[cfg(test)]
172mod tests {
173    use super::*;
174    use crate::common_types::Email as Addr;
175    use crate::email::{AttachmentData, Email};
176
177    #[cot::test]
178    async fn console_error_to_transport_error() {
179        let console_error = ConsoleError::Io(io::Error::other("test error"));
180        let transport_error: TransportError = console_error.into();
181
182        assert_eq!(
183            transport_error.to_string(),
184            "email transport error: transport error: console transport error: IO error: test error"
185        );
186    }
187
188    #[cot::test]
189    async fn display_full_message_renders_all_sections() {
190        let msg = EmailMessage::builder()
191            .from(Addr::new("from@example.com").unwrap())
192            .to(vec![
193                Addr::new("to1@example.com").unwrap(),
194                Addr::new("to2@example.com").unwrap(),
195            ])
196            .cc(vec![
197                Addr::new("cc1@example.com").unwrap(),
198                Addr::new("cc2@example.com").unwrap(),
199            ])
200            .bcc(vec![Addr::new("bcc@example.com").unwrap()])
201            .reply_to(vec![Addr::new("reply@example.com").unwrap()])
202            .subject("Subject Line")
203            .body("Hello body\n")
204            .attachments(vec![
205                AttachmentData {
206                    filename: "a.txt".into(),
207                    content_type: "text/plain".into(),
208                    data: b"abc".to_vec(),
209                },
210                AttachmentData {
211                    filename: "b.pdf".into(),
212                    content_type: "application/pdf".into(),
213                    data: vec![0u8; 10],
214                },
215            ])
216            .build()
217            .unwrap();
218
219        let console = Console::new();
220        let email = Email::new(console);
221        email
222            .send(msg.clone())
223            .await
224            .expect("console send should succeed");
225
226        let rendered = format!("{msg}");
227
228        assert!(rendered.contains("From    : from@example.com"));
229        assert!(rendered.contains("To      : to1@example.com, to2@example.com"));
230        assert!(rendered.contains("Subject : Subject Line"));
231        assert!(rendered.contains("────────────────────────────────────────────────────────"));
232
233        assert!(rendered.contains("Cc      : cc1@example.com, cc2@example.com"));
234        assert!(rendered.contains("Bcc     : bcc@example.com"));
235        assert!(rendered.contains("Reply-To: reply@example.com"));
236
237        assert!(rendered.contains("Hello body"));
238
239        assert!(rendered.contains("Attachments (2):"));
240        assert!(rendered.contains("  - a.txt (3 bytes, text/plain)"));
241        assert!(rendered.contains("  - b.pdf (10 bytes, application/pdf)"));
242
243        assert!(
244            rendered.contains("════════════════════════════════════════════════════════════════")
245        );
246    }
247
248    #[cot::test]
249    async fn display_minimal_message_renders_placeholders_and_omits_optional_headers() {
250        let msg = EmailMessage::builder()
251            .from(Addr::new("sender@example.com").unwrap())
252            // whitespace-only body should render as <empty>
253            .body(" \t\n ")
254            .build()
255            .unwrap();
256
257        let console = Console::default();
258        let email = Email::new(console);
259        email
260            .send(msg.clone())
261            .await
262            .expect("console send should succeed");
263
264        let rendered = format!("{msg}");
265
266        assert!(rendered.contains("From    : sender@example.com"));
267        assert!(rendered.contains("To      : -"));
268        assert!(rendered.contains("Subject : -"));
269
270        assert!(!rendered.contains("Cc      :"));
271        assert!(!rendered.contains("Bcc     :"));
272        assert!(!rendered.contains("Reply-To:"));
273
274        assert!(rendered.contains("<empty>"));
275        assert!(rendered.contains("Attachments: -"));
276    }
277}