koprogo_api/infrastructure/
email.rs

1use lettre::message::{header::ContentType, Mailbox};
2use lettre::transport::smtp::authentication::Credentials;
3use lettre::{Message, SmtpTransport, Transport};
4use std::env;
5use uuid::Uuid;
6
7/// Email service for sending GDPR-related notifications
8#[derive(Clone)]
9pub struct EmailService {
10    smtp_host: String,
11    smtp_port: u16,
12    smtp_username: String,
13    smtp_password: String,
14    from_email: String,
15    from_name: String,
16    enabled: bool,
17}
18
19impl EmailService {
20    /// Create a new email service from environment variables
21    pub fn from_env() -> Result<Self, String> {
22        let enabled = env::var("SMTP_ENABLED")
23            .unwrap_or_else(|_| "false".to_string())
24            .to_lowercase()
25            .parse::<bool>()
26            .unwrap_or(false);
27
28        if !enabled {
29            log::info!("Email service disabled (SMTP_ENABLED=false)");
30            return Ok(Self {
31                smtp_host: String::new(),
32                smtp_port: 0,
33                smtp_username: String::new(),
34                smtp_password: String::new(),
35                from_email: String::new(),
36                from_name: String::new(),
37                enabled: false,
38            });
39        }
40
41        let smtp_host = env::var("SMTP_HOST").map_err(|_| "SMTP_HOST not set".to_string())?;
42        let smtp_port = env::var("SMTP_PORT")
43            .unwrap_or_else(|_| "587".to_string())
44            .parse::<u16>()
45            .map_err(|_| "SMTP_PORT must be a valid port number".to_string())?;
46        let smtp_username =
47            env::var("SMTP_USERNAME").map_err(|_| "SMTP_USERNAME not set".to_string())?;
48        let smtp_password =
49            env::var("SMTP_PASSWORD").map_err(|_| "SMTP_PASSWORD not set".to_string())?;
50        let from_email =
51            env::var("SMTP_FROM_EMAIL").map_err(|_| "SMTP_FROM_EMAIL not set".to_string())?;
52        let from_name = env::var("SMTP_FROM_NAME").unwrap_or_else(|_| "KoproGo".to_string());
53
54        log::info!("Email service enabled: {}:{}", smtp_host, smtp_port);
55
56        Ok(Self {
57            smtp_host,
58            smtp_port,
59            smtp_username,
60            smtp_password,
61            from_email,
62            from_name,
63            enabled: true,
64        })
65    }
66
67    /// Send GDPR data export notification
68    pub async fn send_gdpr_export_notification(
69        &self,
70        user_email: &str,
71        user_name: &str,
72        export_id: Uuid,
73    ) -> Result<(), String> {
74        if !self.enabled {
75            log::debug!(
76                "Email disabled - would send export notification to {}",
77                user_email
78            );
79            return Ok(());
80        }
81
82        let subject = "Your GDPR Data Export is Ready";
83        let body = format!(
84            r#"Dear {},
85
86Your request to export your personal data has been completed.
87
88Export ID: {}
89Export Date: {}
90
91This export contains all personal information we hold about you, in compliance with GDPR Article 15 (Right to Access).
92
93Important Security Notice:
94- This export contains sensitive personal information
95- Please store it securely and do not share it
96- The export will be available for download for 30 days
97
98If you did not request this export, please contact our Data Protection Officer immediately.
99
100Best regards,
101The KoproGo Team
102
103---
104This is an automated message. Please do not reply to this email.
105For questions, contact: dpo@koprogo.com
106"#,
107            user_name,
108            export_id,
109            chrono::Utc::now().format("%Y-%m-%d %H:%M:%S UTC")
110        );
111
112        self.send_email(user_email, subject, &body).await
113    }
114
115    /// Send GDPR data erasure confirmation
116    pub async fn send_gdpr_erasure_notification(
117        &self,
118        user_email: &str,
119        user_name: &str,
120        owners_anonymized: usize,
121    ) -> Result<(), String> {
122        if !self.enabled {
123            log::debug!(
124                "Email disabled - would send erasure notification to {}",
125                user_email
126            );
127            return Ok(());
128        }
129
130        let subject = "GDPR Data Erasure Confirmation";
131        let body = format!(
132            r#"Dear {},
133
134Your request to erase your personal data has been completed.
135
136Erasure Summary:
137- User account: Anonymized
138- Owner profiles: {} anonymized
139- Erasure Date: {}
140
141In compliance with GDPR Article 17 (Right to Erasure), we have anonymized your personal information.
142
143Important Information:
144- Your account can no longer be recovered
145- Some data may be retained for legal compliance (e.g., financial records for 7 years)
146- Anonymous data may be retained for statistical purposes
147
148If you did not request this erasure, please contact us immediately as this action cannot be reversed.
149
150Best regards,
151The KoproGo Team
152
153---
154This is an automated message. Please do not reply to this email.
155For questions, contact: dpo@koprogo.com
156"#,
157            user_name,
158            owners_anonymized,
159            chrono::Utc::now().format("%Y-%m-%d %H:%M:%S UTC")
160        );
161
162        self.send_email(user_email, subject, &body).await
163    }
164
165    /// Send admin-initiated GDPR operation notification
166    pub async fn send_admin_gdpr_notification(
167        &self,
168        user_email: &str,
169        user_name: &str,
170        operation: &str,
171        admin_email: &str,
172    ) -> Result<(), String> {
173        if !self.enabled {
174            log::debug!(
175                "Email disabled - would send admin notification to {}",
176                user_email
177            );
178            return Ok(());
179        }
180
181        let subject = format!("GDPR {} Performed by Administrator", operation);
182        let body_text = format!(
183            r#"Dear {},
184
185An administrator has performed a GDPR {} operation on your account.
186
187Operation: {}
188Performed by: {}
189Date: {}
190
191This operation was performed by a KoproGo administrator, typically in response to:
192- A compliance request
193- Legal obligation
194- Account cleanup
195- Data subject request via alternative channel
196
197If you have questions about this operation, please contact our Data Protection Officer.
198
199Best regards,
200The KoproGo Team
201
202---
203This is an automated message. Please do not reply to this email.
204For questions, contact: dpo@koprogo.com
205"#,
206            user_name,
207            operation,
208            operation,
209            admin_email,
210            chrono::Utc::now().format("%Y-%m-%d %H:%M:%S UTC")
211        );
212
213        self.send_email(user_email, &subject, &body_text).await
214    }
215
216    /// Generic email sending function
217    async fn send_email(&self, to: &str, subject: &str, body: &str) -> Result<(), String> {
218        let from = Mailbox::new(
219            Some(self.from_name.clone()),
220            self.from_email
221                .parse()
222                .map_err(|e| format!("Invalid from email: {}", e))?,
223        );
224
225        let to_mailbox = Mailbox::new(
226            None,
227            to.parse()
228                .map_err(|e| format!("Invalid recipient email: {}", e))?,
229        );
230
231        let email = Message::builder()
232            .from(from)
233            .to(to_mailbox)
234            .subject(subject)
235            .header(ContentType::TEXT_PLAIN)
236            .body(body.to_string())
237            .map_err(|e| format!("Failed to build email: {}", e))?;
238
239        let creds = Credentials::new(self.smtp_username.clone(), self.smtp_password.clone());
240
241        let mailer = SmtpTransport::relay(&self.smtp_host)
242            .map_err(|e| format!("Failed to create SMTP transport: {}", e))?
243            .port(self.smtp_port)
244            .credentials(creds)
245            .build();
246
247        // Send email in blocking thread to avoid blocking async runtime
248        let send_result = tokio::task::spawn_blocking(move || mailer.send(&email))
249            .await
250            .map_err(|e| format!("Failed to spawn blocking task: {}", e))?;
251
252        send_result.map_err(|e| format!("Failed to send email: {}", e))?;
253
254        log::info!("Email sent successfully to {}: {}", to, subject);
255        Ok(())
256    }
257}
258
259#[cfg(test)]
260mod tests {
261    use super::*;
262
263    #[test]
264    fn test_email_service_disabled() {
265        std::env::set_var("SMTP_ENABLED", "false");
266        let service = EmailService::from_env().unwrap();
267        assert!(!service.enabled);
268    }
269
270    #[tokio::test]
271    async fn test_send_export_notification_disabled() {
272        std::env::set_var("SMTP_ENABLED", "false");
273        let service = EmailService::from_env().unwrap();
274        let result = service
275            .send_gdpr_export_notification("test@example.com", "Test User", Uuid::new_v4())
276            .await;
277        assert!(result.is_ok());
278    }
279
280    #[tokio::test]
281    async fn test_send_erasure_notification_disabled() {
282        std::env::set_var("SMTP_ENABLED", "false");
283        let service = EmailService::from_env().unwrap();
284        let result = service
285            .send_gdpr_erasure_notification("test@example.com", "Test User", 2)
286            .await;
287        assert!(result.is_ok());
288    }
289}