1pub mod config;
14pub mod error;
15pub mod providers;
16pub mod templates;
17pub mod tracking;
18pub mod mailable;
19pub mod validation;
20pub mod compression;
21pub mod queue;
22
23#[cfg(feature = "integration-examples")]
24pub mod integration_example;
25
26pub use config::*;
27pub use error::*;
28pub use mailable::*;
29pub use providers::*;
30#[cfg(test)]
31pub use providers::{MockEmailProvider, PanickingEmailProvider};
32pub use templates::*;
33pub use tracking::*;
34pub use validation::*;
35pub use compression::*;
36pub use queue::*;
37
38use async_trait::async_trait;
39use serde::{Deserialize, Serialize};
40use std::collections::HashMap;
41use uuid::Uuid;
42
43#[derive(Debug, Clone, Serialize, Deserialize)]
45pub struct Email {
46 pub id: Uuid,
48 pub from: String,
50 pub to: Vec<String>,
52 pub cc: Option<Vec<String>>,
54 pub bcc: Option<Vec<String>>,
56 pub reply_to: Option<String>,
58 pub subject: String,
60 pub html_body: Option<String>,
62 pub text_body: Option<String>,
64 pub attachments: Vec<Attachment>,
66 pub headers: HashMap<String, String>,
68 pub tracking: TrackingOptions,
70}
71
72#[derive(Debug, Clone, Serialize, Deserialize)]
74pub struct Attachment {
75 pub filename: String,
77 pub content_type: String,
79 pub content: Vec<u8>,
81 pub content_id: Option<String>,
83 pub disposition: AttachmentDisposition,
85 pub size: usize,
87 pub compressed: bool,
89}
90
91#[derive(Debug, Clone, Serialize, Deserialize)]
93pub enum AttachmentDisposition {
94 Attachment,
96 Inline,
98}
99
100#[derive(Debug, Clone, Serialize, Deserialize)]
102pub struct TrackingOptions {
103 pub track_opens: bool,
105 pub track_clicks: bool,
107 pub custom_params: HashMap<String, String>,
109}
110
111impl Default for TrackingOptions {
112 fn default() -> Self {
113 Self {
114 track_opens: false,
115 track_clicks: false,
116 custom_params: HashMap::new(),
117 }
118 }
119}
120
121#[async_trait]
123pub trait EmailProvider: Send + Sync {
124 async fn send(&self, email: &Email) -> Result<EmailResult, EmailError>;
126
127 async fn validate_config(&self) -> Result<(), EmailError>;
129
130 fn provider_name(&self) -> &'static str;
132}
133
134#[derive(Debug, Clone, Serialize, Deserialize)]
136pub struct EmailResult {
137 pub email_id: Uuid,
139 pub message_id: String,
141 pub sent_at: chrono::DateTime<chrono::Utc>,
143 pub provider: String,
145}
146
147impl Email {
148 pub fn new() -> Self {
150 Self {
151 id: Uuid::new_v4(),
152 from: String::new(),
153 to: Vec::new(),
154 cc: None,
155 bcc: None,
156 reply_to: None,
157 subject: String::new(),
158 html_body: None,
159 text_body: None,
160 attachments: Vec::new(),
161 headers: HashMap::new(),
162 tracking: TrackingOptions::default(),
163 }
164 }
165
166 pub fn from(mut self, from: impl Into<String>) -> Self {
168 self.from = from.into();
169 self
170 }
171
172 pub fn to(mut self, to: impl Into<String>) -> Self {
174 self.to.push(to.into());
175 self
176 }
177
178 pub fn subject(mut self, subject: impl Into<String>) -> Self {
180 self.subject = subject.into();
181 self
182 }
183
184 pub fn html_body(mut self, html: impl Into<String>) -> Self {
186 self.html_body = Some(html.into());
187 self
188 }
189
190 pub fn text_body(mut self, text: impl Into<String>) -> Self {
192 self.text_body = Some(text.into());
193 self
194 }
195
196 pub fn attach(mut self, attachment: Attachment) -> Self {
198 self.attachments.push(attachment);
199 self
200 }
201
202 pub fn with_tracking(mut self, track_opens: bool, track_clicks: bool) -> Self {
204 self.tracking.track_opens = track_opens;
205 self.tracking.track_clicks = track_clicks;
206 self
207 }
208
209 pub fn attach_inline(mut self, attachment: Attachment) -> Self {
211 self.attachments.push(attachment);
212 self
213 }
214
215 pub fn validate_attachments(&self, config: &crate::config::AttachmentConfig) -> Result<(), crate::EmailError> {
217 crate::compression::validate_attachments(&self.attachments, config)
218 }
219
220 pub fn inline_attachments(&self) -> Vec<&Attachment> {
222 self.attachments.iter()
223 .filter(|a| matches!(a.disposition, AttachmentDisposition::Inline))
224 .collect()
225 }
226
227 pub fn regular_attachments(&self) -> Vec<&Attachment> {
229 self.attachments.iter()
230 .filter(|a| matches!(a.disposition, AttachmentDisposition::Attachment))
231 .collect()
232 }
233
234 pub fn compress_attachments(&mut self, config: &crate::config::AttachmentConfig) -> Result<(), crate::EmailError> {
236 for attachment in &mut self.attachments {
237 crate::compression::AttachmentCompressor::compress_if_beneficial(attachment, config)?;
238 }
239 Ok(())
240 }
241
242 pub fn estimated_compressed_size(&self) -> usize {
244 self.attachments.iter()
245 .map(|a| {
246 if a.compressed {
247 a.size
248 } else {
249 let ratio = crate::compression::AttachmentCompressor::estimate_compression_ratio(a);
250 (a.size as f64 * ratio) as usize
251 }
252 })
253 .sum()
254 }
255}
256
257impl Default for Email {
258 fn default() -> Self {
259 Self::new()
260 }
261}
262
263impl Attachment {
264 pub fn new(filename: impl Into<String>, content: Vec<u8>) -> Self {
266 let filename = filename.into();
267 let content_type = mime_guess::from_path(&filename)
268 .first()
269 .map(|m| m.to_string())
270 .unwrap_or_else(|| "application/octet-stream".to_string());
271
272 let size = content.len();
273
274 Self {
275 filename,
276 content_type,
277 content,
278 content_id: None,
279 disposition: AttachmentDisposition::Attachment,
280 size,
281 compressed: false,
282 }
283 }
284
285 pub fn inline(filename: impl Into<String>, content: Vec<u8>, content_id: impl Into<String>) -> Self {
287 let mut attachment = Self::new(filename, content);
288 attachment.disposition = AttachmentDisposition::Inline;
289 attachment.content_id = Some(content_id.into());
290 attachment
291 }
292
293 pub fn with_content_type(mut self, content_type: impl Into<String>) -> Self {
295 self.content_type = content_type.into();
296 self
297 }
298
299 pub fn as_inline(mut self, content_id: impl Into<String>) -> Self {
301 self.disposition = AttachmentDisposition::Inline;
302 self.content_id = Some(content_id.into());
303 self
304 }
305
306 pub fn is_image(&self) -> bool {
308 self.content_type.starts_with("image/")
309 }
310
311 pub fn can_compress(&self) -> bool {
313 matches!(self.content_type.as_str(),
314 "image/jpeg" | "image/png" | "image/webp" |
315 "text/plain" | "text/html" | "text/css" | "text/javascript" |
316 "application/json" | "application/xml"
317 )
318 }
319
320 pub fn validate(&self, config: &crate::config::AttachmentConfig) -> Result<(), crate::EmailError> {
322 if self.size > config.max_size {
324 return Err(crate::EmailError::validation(
325 "attachment_size",
326 format!("Attachment '{}' is too large: {} bytes (max: {} bytes)",
327 self.filename, self.size, config.max_size)
328 ));
329 }
330
331 if !config.allowed_types.is_empty() && !config.allowed_types.contains(&self.content_type) {
333 return Err(crate::EmailError::validation(
334 "attachment_type",
335 format!("Attachment type '{}' is not allowed", self.content_type)
336 ));
337 }
338
339 if config.blocked_types.contains(&self.content_type) {
341 return Err(crate::EmailError::validation(
342 "attachment_type",
343 format!("Attachment type '{}' is blocked", self.content_type)
344 ));
345 }
346
347 Ok(())
348 }
349}
350
351impl Default for AttachmentDisposition {
352 fn default() -> Self {
353 AttachmentDisposition::Attachment
354 }
355}