cot/email/transport/
console.rs1use 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#[derive(Debug, Error)]
40#[non_exhaustive]
41pub enum ConsoleError {
42 #[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#[derive(Debug, Clone, Copy)]
63#[non_exhaustive]
64pub struct Console;
65
66impl Console {
67 #[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 .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}