Skip to main content

reinhardt_mail/
message.rs

1use std::path::PathBuf;
2
3/// Represents an alternative content type for an email message.
4///
5/// Alternatives allow providing different representations of the same content,
6/// typically used for HTML vs. plain text versions.
7///
8/// # Examples
9///
10/// ```
11/// use reinhardt_mail::Alternative;
12///
13/// let alternative = Alternative::new("text/html", "<h1>Hello!</h1>".as_bytes().to_vec());
14/// assert_eq!(alternative.content_type(), "text/html");
15/// ```
16#[derive(Debug, Clone)]
17pub struct Alternative {
18	/// MIME content type (e.g., "text/html", "text/plain")
19	content_type: String,
20	/// Content data as bytes
21	content: Vec<u8>,
22}
23
24impl Alternative {
25	/// Create a new alternative content
26	///
27	/// # Examples
28	///
29	/// ```
30	/// use reinhardt_mail::Alternative;
31	///
32	/// let html = Alternative::new("text/html", b"<h1>Hello</h1>".to_vec());
33	/// assert_eq!(html.content_type(), "text/html");
34	/// ```
35	pub fn new(content_type: impl Into<String>, content: Vec<u8>) -> Self {
36		Self {
37			content_type: content_type.into(),
38			content,
39		}
40	}
41
42	/// Create an HTML alternative
43	///
44	/// # Examples
45	///
46	/// ```
47	/// use reinhardt_mail::Alternative;
48	///
49	/// let html = Alternative::html("<h1>Welcome!</h1>");
50	/// assert_eq!(html.content_type(), "text/html");
51	/// ```
52	pub fn html(content: impl Into<String>) -> Self {
53		let content_str = content.into();
54		Self::new("text/html", content_str.into_bytes())
55	}
56
57	/// Create a plain text alternative
58	///
59	/// # Examples
60	///
61	/// ```
62	/// use reinhardt_mail::Alternative;
63	///
64	/// let text = Alternative::plain("Welcome!");
65	/// assert_eq!(text.content_type(), "text/plain");
66	/// ```
67	pub fn plain(content: impl Into<String>) -> Self {
68		let content_str = content.into();
69		Self::new("text/plain", content_str.into_bytes())
70	}
71
72	/// Get the content type
73	pub fn content_type(&self) -> &str {
74		&self.content_type
75	}
76
77	/// Get the content as bytes
78	pub fn content(&self) -> &[u8] {
79		&self.content
80	}
81
82	/// Get the content as a string slice (if valid UTF-8)
83	pub fn content_as_string(&self) -> Option<&str> {
84		std::str::from_utf8(&self.content).ok()
85	}
86}
87
88/// Represents a file attachment for an email message.
89///
90/// Attachments can be created from file paths or raw bytes.
91/// Supports automatic MIME type detection based on file extension.
92///
93/// # Examples
94///
95/// ```
96/// use reinhardt_mail::Attachment;
97/// use std::path::PathBuf;
98///
99/// // From bytes
100/// let data = b"Hello, world!".to_vec();
101/// let attachment = Attachment::new("hello.txt", data);
102/// assert_eq!(attachment.filename(), "hello.txt");
103///
104/// // From file path
105/// let path = PathBuf::from("/path/to/file.pdf");
106/// let attachment = Attachment::from_path(path, "document.pdf");
107/// ```
108#[derive(Debug, Clone)]
109pub struct Attachment {
110	/// Original filename
111	filename: String,
112	/// File content as bytes
113	content: Vec<u8>,
114	/// MIME content type (auto-detected or specified)
115	mime_type: String,
116	/// Content-ID for inline attachments (e.g., for embedded images)
117	content_id: Option<String>,
118	/// Whether this is an inline attachment
119	inline: bool,
120}
121
122impl Attachment {
123	/// Create a new attachment from bytes
124	///
125	/// MIME type is automatically detected from the filename extension.
126	///
127	/// # Examples
128	///
129	/// ```
130	/// use reinhardt_mail::Attachment;
131	///
132	/// let data = b"PDF content".to_vec();
133	/// let attachment = Attachment::new("document.pdf", data);
134	/// assert_eq!(attachment.filename(), "document.pdf");
135	/// assert!(attachment.mime_type().contains("pdf"));
136	/// ```
137	pub fn new(filename: impl Into<String>, content: Vec<u8>) -> Self {
138		let filename_str = filename.into();
139		let mime_type = Self::detect_mime_type(&filename_str);
140
141		Self {
142			filename: filename_str,
143			content,
144			mime_type,
145			content_id: None,
146			inline: false,
147		}
148	}
149
150	/// Create a new attachment from a file path
151	///
152	/// # Examples
153	///
154	/// ```no_run
155	/// use reinhardt_mail::Attachment;
156	/// use std::path::PathBuf;
157	///
158	/// # fn main() -> std::io::Result<()> {
159	/// let path = PathBuf::from("/tmp/test.txt");
160	/// let attachment = Attachment::from_path(path, "report.txt")?;
161	/// assert_eq!(attachment.filename(), "report.txt");
162	/// # Ok(())
163	/// # }
164	/// ```
165	pub fn from_path(path: PathBuf, filename: impl Into<String>) -> std::io::Result<Self> {
166		// Read file contents from disk
167		let content = std::fs::read(&path)?;
168
169		let filename_str = filename.into();
170		let mime_type = Self::detect_mime_type(&filename_str);
171
172		Ok(Self {
173			filename: filename_str,
174			content,
175			mime_type,
176			content_id: None,
177			inline: false,
178		})
179	}
180
181	/// Create an inline attachment (for embedded images, etc.)
182	///
183	/// # Examples
184	///
185	/// ```
186	/// use reinhardt_mail::Attachment;
187	///
188	/// let image_data = b"\x89PNG\r\n\x1a\n".to_vec(); // PNG header
189	/// let attachment = Attachment::inline("logo.png", image_data, "logo-cid");
190	/// assert!(attachment.is_inline());
191	/// assert_eq!(attachment.content_id(), Some("logo-cid"));
192	/// ```
193	pub fn inline(
194		filename: impl Into<String>,
195		content: Vec<u8>,
196		content_id: impl Into<String>,
197	) -> Self {
198		let filename_str = filename.into();
199		let mime_type = Self::detect_mime_type(&filename_str);
200
201		Self {
202			filename: filename_str,
203			content,
204			mime_type,
205			content_id: Some(content_id.into()),
206			inline: true,
207		}
208	}
209
210	/// Set a custom MIME type
211	///
212	/// # Examples
213	///
214	/// ```
215	/// use reinhardt_mail::Attachment;
216	///
217	/// let mut attachment = Attachment::new("data.bin", vec![1, 2, 3]);
218	/// attachment.with_mime_type("application/octet-stream");
219	/// assert_eq!(attachment.mime_type(), "application/octet-stream");
220	/// ```
221	pub fn with_mime_type(&mut self, mime_type: impl Into<String>) -> &mut Self {
222		self.mime_type = mime_type.into();
223		self
224	}
225
226	/// Set as inline attachment with content ID
227	///
228	/// # Examples
229	///
230	/// ```
231	/// use reinhardt_mail::Attachment;
232	///
233	/// let mut attachment = Attachment::new("logo.png", vec![]);
234	/// attachment.as_inline("logo-123");
235	/// assert!(attachment.is_inline());
236	/// ```
237	pub fn as_inline(&mut self, content_id: impl Into<String>) -> &mut Self {
238		self.content_id = Some(content_id.into());
239		self.inline = true;
240		self
241	}
242
243	/// Get the filename
244	pub fn filename(&self) -> &str {
245		&self.filename
246	}
247
248	/// Get the content
249	pub fn content(&self) -> &[u8] {
250		&self.content
251	}
252
253	/// Get the MIME type
254	pub fn mime_type(&self) -> &str {
255		&self.mime_type
256	}
257
258	/// Get the content ID (for inline attachments)
259	pub fn content_id(&self) -> Option<&str> {
260		self.content_id.as_deref()
261	}
262
263	/// Check if this is an inline attachment
264	pub fn is_inline(&self) -> bool {
265		self.inline
266	}
267
268	/// Detect MIME type from filename
269	fn detect_mime_type(filename: &str) -> String {
270		mime_guess::from_path(filename)
271			.first()
272			.map(|mime| mime.to_string())
273			.unwrap_or_else(|| "application/octet-stream".to_string())
274	}
275}
276
277/// Represents an email message with validated addresses.
278///
279/// All fields are private to enforce validation through the builder.
280/// Use getter methods for read access and the builder for construction.
281/// Direct field assignment is not possible, preventing bypass of validation.
282#[derive(Debug, Clone)]
283pub struct EmailMessage {
284	subject: String,
285	body: String,
286	from_email: String,
287	to: Vec<String>,
288	cc: Vec<String>,
289	bcc: Vec<String>,
290	reply_to: Vec<String>,
291	html_body: Option<String>,
292	alternatives: Vec<Alternative>,
293	attachments: Vec<Attachment>,
294	headers: Vec<(String, String)>,
295}
296
297impl EmailMessage {
298	/// Create a new builder for constructing an `EmailMessage`.
299	pub fn builder() -> EmailMessageBuilder {
300		EmailMessageBuilder::default()
301	}
302
303	/// Get the subject.
304	pub fn subject(&self) -> &str {
305		&self.subject
306	}
307
308	/// Get the body.
309	pub fn body(&self) -> &str {
310		&self.body
311	}
312
313	/// Get the from email address.
314	pub fn from_email(&self) -> &str {
315		&self.from_email
316	}
317
318	/// Get the list of recipients.
319	pub fn to(&self) -> &[String] {
320		&self.to
321	}
322
323	/// Get the list of CC recipients.
324	pub fn cc(&self) -> &[String] {
325		&self.cc
326	}
327
328	/// Get the list of BCC recipients.
329	pub fn bcc(&self) -> &[String] {
330		&self.bcc
331	}
332
333	/// Get the list of reply-to addresses.
334	pub fn reply_to(&self) -> &[String] {
335		&self.reply_to
336	}
337
338	/// Get the HTML body.
339	pub fn html_body(&self) -> Option<&str> {
340		self.html_body.as_deref()
341	}
342
343	/// Get the alternatives.
344	pub fn alternatives(&self) -> &[Alternative] {
345		&self.alternatives
346	}
347
348	/// Get the attachments.
349	pub fn attachments(&self) -> &[Attachment] {
350		&self.attachments
351	}
352
353	/// Get the custom headers.
354	pub fn headers(&self) -> &[(String, String)] {
355		&self.headers
356	}
357
358	/// Send the email using the given backend.
359	pub async fn send(
360		&self,
361		backend: &dyn crate::backends::EmailBackend,
362	) -> crate::EmailResult<()> {
363		backend.send_messages(std::slice::from_ref(self)).await?;
364		Ok(())
365	}
366
367	/// Send the email using the given backend (alias for `send`).
368	pub async fn send_with_backend(
369		&self,
370		backend: &dyn crate::backends::EmailBackend,
371	) -> crate::EmailResult<()> {
372		backend.send_messages(std::slice::from_ref(self)).await?;
373		Ok(())
374	}
375}
376
377#[derive(Default)]
378pub struct EmailMessageBuilder {
379	subject: String,
380	body: String,
381	from_email: String,
382	to: Vec<String>,
383	cc: Vec<String>,
384	bcc: Vec<String>,
385	reply_to: Vec<String>,
386	html_body: Option<String>,
387	alternatives: Vec<Alternative>,
388	attachments: Vec<Attachment>,
389	headers: Vec<(String, String)>,
390}
391
392impl EmailMessageBuilder {
393	pub fn subject(mut self, subject: impl Into<String>) -> Self {
394		self.subject = subject.into();
395		self
396	}
397
398	pub fn body(mut self, body: impl Into<String>) -> Self {
399		self.body = body.into();
400		self
401	}
402
403	pub fn from(mut self, from: impl Into<String>) -> Self {
404		self.from_email = from.into();
405		self
406	}
407
408	pub fn from_email(mut self, from: impl Into<String>) -> Self {
409		self.from_email = from.into();
410		self
411	}
412
413	pub fn to(mut self, to: Vec<String>) -> Self {
414		self.to = to;
415		self
416	}
417
418	pub fn cc(mut self, cc: Vec<String>) -> Self {
419		self.cc = cc;
420		self
421	}
422
423	pub fn bcc(mut self, bcc: Vec<String>) -> Self {
424		self.bcc = bcc;
425		self
426	}
427
428	pub fn reply_to(mut self, reply_to: Vec<String>) -> Self {
429		self.reply_to = reply_to;
430		self
431	}
432
433	pub fn html(mut self, html: impl Into<String>) -> Self {
434		self.html_body = Some(html.into());
435		self
436	}
437
438	pub fn attachment(mut self, attachment: Attachment) -> Self {
439		self.attachments.push(attachment);
440		self
441	}
442
443	pub fn alternative(mut self, alternative: Alternative) -> Self {
444		self.alternatives.push(alternative);
445		self
446	}
447
448	pub fn header(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
449		self.headers.push((name.into(), value.into()));
450		self
451	}
452
453	/// Build the email message with validation.
454	///
455	/// Validates all email addresses using `validate_email()` and checks
456	/// subject/header values for header injection attacks before
457	/// constructing the message. Returns an error if any validation fails.
458	pub fn build(self) -> crate::EmailResult<EmailMessage> {
459		use crate::validation::{
460			check_header_injection, validate_email, validate_email_list, validate_header_name,
461		};
462
463		// Validate from_email if provided
464		if !self.from_email.is_empty() {
465			validate_email(&self.from_email)?;
466		}
467
468		// Validate recipient lists
469		validate_email_list(&self.to)?;
470		validate_email_list(&self.cc)?;
471		validate_email_list(&self.bcc)?;
472		validate_email_list(&self.reply_to)?;
473
474		// Validate subject for header injection
475		check_header_injection(&self.subject)?;
476
477		// Validate custom header names (RFC 2822) and values (injection)
478		for (name, value) in &self.headers {
479			validate_header_name(name)?;
480			check_header_injection(value)?;
481		}
482
483		Ok(EmailMessage {
484			subject: self.subject,
485			body: self.body,
486			from_email: self.from_email,
487			to: self.to,
488			cc: self.cc,
489			bcc: self.bcc,
490			reply_to: self.reply_to,
491			html_body: self.html_body,
492			alternatives: self.alternatives,
493			attachments: self.attachments,
494			headers: self.headers,
495		})
496	}
497}