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#[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 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 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 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 fn convert_email(&self, email: &Email) -> Result<Form, EmailError> {
59 let mut form = Form::new();
60
61 form = form.text("from", email.from.clone());
63
64 let to_list = email.to.join(",");
66 form = form.text("to", to_list);
67
68 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 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 if let Some(reply_to) = &email.reply_to {
84 form = form.text("h:Reply-To", reply_to.clone());
85 }
86
87 form = form.text("subject", email.subject.clone());
89
90 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 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 for (key, value) in &email.headers {
105 form = form.text(format!("h:{}", key), value.clone());
106 }
107
108 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 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 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 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 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
235mod 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 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 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}