elif_email/providers/
mailgun.rs

1use crate::{config::MailgunConfig, Email, EmailError, EmailProvider, EmailResult};
2use async_trait::async_trait;
3use reqwest::{Client, multipart::{Form, Part}, header::{HeaderMap, HeaderValue, AUTHORIZATION}};
4use serde::Deserialize;
5use std::time::Duration;
6use tracing::{debug, error};
7
8/// Mailgun email provider using reqwest HTTP client
9#[derive(Clone)]
10pub struct MailgunProvider {
11    config: MailgunConfig,
12    client: Client,
13}
14
15#[derive(Debug, Deserialize)]
16struct MailgunResponse {
17    id: Option<String>,
18    message: String,
19}
20
21impl MailgunProvider {
22    /// Create new Mailgun provider
23    pub fn new(config: MailgunConfig) -> Result<Self, EmailError> {
24        let timeout = config.timeout.unwrap_or(30);
25        let client = Client::builder()
26            .timeout(Duration::from_secs(timeout))
27            .build()
28            .map_err(|e| EmailError::configuration(format!("Failed to create HTTP client: {}", e)))?;
29
30        Ok(Self { config, client })
31    }
32
33    /// Get Mailgun API endpoint
34    fn get_endpoint(&self) -> String {
35        let region = self.config.region.as_deref().unwrap_or("us");
36        match region {
37            "eu" => format!("https://api.eu.mailgun.net/v3/{}/messages", self.config.domain),
38            _ => format!("https://api.mailgun.net/v3/{}/messages", self.config.domain),
39        }
40    }
41
42    /// Build request headers
43    fn build_headers(&self) -> Result<HeaderMap, EmailError> {
44        let mut headers = HeaderMap::new();
45        
46        let auth_string = format!("api:{}", self.config.api_key);
47        let auth_header = format!("Basic {}", base64::encode(auth_string.as_bytes()));
48        headers.insert(
49            AUTHORIZATION,
50            HeaderValue::from_str(&auth_header)
51                .map_err(|e| EmailError::configuration(format!("Invalid API key format: {}", e)))?,
52        );
53
54        Ok(headers)
55    }
56
57    /// Convert elif Email to Mailgun multipart form
58    fn convert_email(&self, email: &Email) -> Result<Form, EmailError> {
59        let mut form = Form::new();
60
61        // From address
62        form = form.text("from", email.from.clone());
63
64        // To addresses
65        let to_list = email.to.join(",");
66        form = form.text("to", to_list);
67
68        // CC addresses
69        if let Some(cc_list) = &email.cc {
70            if !cc_list.is_empty() {
71                form = form.text("cc", cc_list.join(","));
72            }
73        }
74
75        // BCC addresses
76        if let Some(bcc_list) = &email.bcc {
77            if !bcc_list.is_empty() {
78                form = form.text("bcc", bcc_list.join(","));
79            }
80        }
81
82        // Reply-to
83        if let Some(reply_to) = &email.reply_to {
84            form = form.text("h:Reply-To", reply_to.clone());
85        }
86
87        // Subject
88        form = form.text("subject", email.subject.clone());
89
90        // Body content
91        if let Some(text) = &email.text_body {
92            form = form.text("text", text.clone());
93        }
94        if let Some(html) = &email.html_body {
95            form = form.text("html", html.clone());
96        }
97
98        // Validate that we have at least one body
99        if email.text_body.is_none() && email.html_body.is_none() {
100            return Err(EmailError::validation("body", "Email must have either HTML or text body"));
101        }
102
103        // Custom headers
104        for (key, value) in &email.headers {
105            form = form.text(format!("h:{}", key), value.clone());
106        }
107
108        // Tracking options
109        if email.tracking.track_opens {
110            form = form.text("o:tracking-opens", "true");
111        }
112        if email.tracking.track_clicks {
113            form = form.text("o:tracking-clicks", "true");
114        }
115
116        // Custom variables for tracking
117        form = form.text("v:email_id", email.id.to_string());
118        for (key, value) in &email.tracking.custom_params {
119            form = form.text(format!("v:{}", key), value.clone());
120        }
121
122        // Attachments
123        for attachment in &email.attachments {
124            let part = Part::bytes(attachment.content.clone())
125                .file_name(attachment.filename.clone())
126                .mime_str(&attachment.content_type)
127                .map_err(|e| {
128                    EmailError::validation(
129                        "attachment",
130                        format!("Invalid content type '{}': {}", attachment.content_type, e),
131                    )
132                })?;
133
134            match attachment.disposition {
135                crate::AttachmentDisposition::Inline => {
136                    form = form.part("inline", part);
137                }
138                crate::AttachmentDisposition::Attachment => {
139                    form = form.part("attachment", part);
140                }
141            }
142        }
143
144        Ok(form)
145    }
146}
147
148#[async_trait]
149impl EmailProvider for MailgunProvider {
150    async fn send(&self, email: &Email) -> Result<EmailResult, EmailError> {
151        debug!(
152            "Sending email via Mailgun: {} -> {:?}",
153            email.from, email.to
154        );
155
156        let form = self.convert_email(email)?;
157        let headers = self.build_headers()?;
158        let endpoint = self.get_endpoint();
159
160        let response = self
161            .client
162            .post(&endpoint)
163            .headers(headers)
164            .multipart(form)
165            .send()
166            .await?;
167
168        let status = response.status();
169        let response_text = response.text().await?;
170
171        if status.is_success() {
172            // Try to parse the response to get the message ID
173            let message_id = if let Ok(mailgun_response) = serde_json::from_str::<MailgunResponse>(&response_text) {
174                mailgun_response.id.unwrap_or_else(|| format!("mailgun-{}", email.id))
175            } else {
176                format!("mailgun-{}", email.id)
177            };
178
179            Ok(EmailResult {
180                email_id: email.id,
181                message_id,
182                sent_at: chrono::Utc::now(),
183                provider: "mailgun".to_string(),
184            })
185        } else {
186            let error_msg = if let Ok(mailgun_response) = serde_json::from_str::<MailgunResponse>(&response_text) {
187                mailgun_response.message
188            } else {
189                format!("HTTP {}: {}", status, response_text)
190            };
191
192            error!("Mailgun send failed: {}", error_msg);
193            Err(EmailError::provider("Mailgun", error_msg))
194        }
195    }
196
197    async fn validate_config(&self) -> Result<(), EmailError> {
198        debug!("Validating Mailgun configuration for domain: {}", self.config.domain);
199
200        let headers = self.build_headers()?;
201        
202        // Test with domain info endpoint to validate the API key and domain
203        let region = self.config.region.as_deref().unwrap_or("us");
204        let test_endpoint = match region {
205            "eu" => format!("https://api.eu.mailgun.net/v3/{}", self.config.domain),
206            _ => format!("https://api.mailgun.net/v3/{}", self.config.domain),
207        };
208        
209        let response = self
210            .client
211            .get(&test_endpoint)
212            .headers(headers)
213            .send()
214            .await?;
215
216        if response.status().is_success() {
217            debug!("Mailgun configuration validation successful");
218            Ok(())
219        } else {
220            let status = response.status();
221            let error_text = response.text().await.unwrap_or_default();
222            error!("Mailgun configuration validation failed: {} - {}", status, error_text);
223            Err(EmailError::configuration(format!(
224                "Mailgun configuration validation failed: {} - {}",
225                status, error_text
226            )))
227        }
228    }
229
230    fn provider_name(&self) -> &'static str {
231        "mailgun"
232    }
233}
234
235// Re-use base64 encoding from SendGrid
236mod base64 {
237    use base64::engine::general_purpose::STANDARD;
238    use base64::Engine;
239
240    pub fn encode(data: &[u8]) -> String {
241        STANDARD.encode(data)
242    }
243}
244
245#[cfg(test)]
246mod tests {
247    use super::*;
248
249    #[test]
250    fn test_mailgun_provider_creation() {
251        let config = MailgunConfig::new("test-api-key", "mg.example.com");
252        let provider = MailgunProvider::new(config);
253        assert!(provider.is_ok());
254    }
255
256    #[test]
257    fn test_endpoint_generation() {
258        // US region (default)
259        let config = MailgunConfig::new("test-key", "mg.example.com");
260        let provider = MailgunProvider::new(config).unwrap();
261        assert_eq!(
262            provider.get_endpoint(),
263            "https://api.mailgun.net/v3/mg.example.com/messages"
264        );
265
266        // EU region
267        let mut config = MailgunConfig::new("test-key", "mg.example.com");
268        config.region = Some("eu".to_string());
269        let provider = MailgunProvider::new(config).unwrap();
270        assert_eq!(
271            provider.get_endpoint(),
272            "https://api.eu.mailgun.net/v3/mg.example.com/messages"
273        );
274    }
275
276    #[test]
277    fn test_email_conversion() {
278        let config = MailgunConfig::new("test-api-key", "mg.example.com");
279        let provider = MailgunProvider::new(config).unwrap();
280
281        let email = Email::new()
282            .from("sender@example.com")
283            .to("recipient@example.com")
284            .subject("Test Email")
285            .text_body("Hello, World!");
286
287        let result = provider.convert_email(&email);
288        assert!(result.is_ok());
289    }
290
291    #[test]
292    fn test_email_with_tracking() {
293        let config = MailgunConfig::new("test-api-key", "mg.example.com");
294        let provider = MailgunProvider::new(config).unwrap();
295
296        let email = Email::new()
297            .from("sender@example.com")
298            .to("recipient@example.com")
299            .subject("Test Email")
300            .text_body("Hello, World!")
301            .with_tracking(true, true);
302
303        let result = provider.convert_email(&email);
304        assert!(result.is_ok());
305    }
306}