acton_htmx/email/
job.rs

1//! Background job for sending emails
2//!
3//! Integrates with the job system to send emails asynchronously.
4
5use async_trait::async_trait;
6use serde::{Deserialize, Serialize};
7
8use crate::email::Email;
9use crate::jobs::{Job, JobContext, JobError, JobResult};
10
11/// Background job for sending emails
12///
13/// Use this to send emails asynchronously via the job queue.
14///
15/// # Examples
16///
17/// ```rust,no_run
18/// use acton_htmx::email::{Email, SendEmailJob};
19/// use acton_htmx::jobs::{Job, JobAgent};
20///
21/// # async fn example(job_agent: &JobAgent) -> Result<(), Box<dyn std::error::Error>> {
22/// let email = Email::new()
23///     .to("user@example.com")
24///     .from("noreply@myapp.com")
25///     .subject("Welcome!")
26///     .text("Welcome to our app!");
27///
28/// let job = SendEmailJob::new(email);
29/// job_agent.enqueue(job).await?;
30/// # Ok(())
31/// # }
32/// ```
33#[derive(Debug, Clone, Serialize, Deserialize)]
34pub struct SendEmailJob {
35    /// The email to send
36    pub email: Email,
37}
38
39impl SendEmailJob {
40    /// Create a new email sending job
41    #[must_use]
42    pub const fn new(email: Email) -> Self {
43        Self { email }
44    }
45}
46
47#[async_trait]
48impl Job for SendEmailJob {
49    type Result = ();
50
51    async fn execute(&self, ctx: &JobContext) -> JobResult<Self::Result> {
52        // Validate email first
53        self.email.validate()
54            .map_err(|e| JobError::ExecutionFailed(format!("Email validation failed: {e}")))?;
55
56        // Get email sender from context
57        let Some(email_sender) = ctx.email_sender() else {
58            return Err(JobError::ExecutionFailed(
59                "Email sender not available in JobContext".to_string()
60            ));
61        };
62
63        // Send the email
64        email_sender.send(self.email.clone()).await
65            .map_err(|e| JobError::ExecutionFailed(format!("Email send failed: {e}")))?;
66
67        Ok(())
68    }
69
70    fn max_retries(&self) -> u32 {
71        // Retry email sending up to 3 times
72        3
73    }
74
75    fn timeout(&self) -> std::time::Duration {
76        // Email sending should complete within 30 seconds
77        std::time::Duration::from_secs(30)
78    }
79}
80
81// Note: In a future iteration, we could add an EmailJobExt trait
82// to provide convenient methods for enqueueing email jobs directly
83
84#[cfg(test)]
85mod tests {
86    use super::*;
87    use crate::email::sender::MockEmailSender;  // Use mockall-generated mock
88    use std::sync::Arc;
89
90    #[test]
91    fn test_send_email_job_creation() {
92        let email = Email::new()
93            .to("user@example.com")
94            .from("noreply@myapp.com")
95            .subject("Test")
96            .text("Hello");
97
98        let job = SendEmailJob::new(email.clone());
99
100        assert_eq!(job.email.to, email.to);
101        assert_eq!(job.email.from, email.from);
102        assert_eq!(job.email.subject, email.subject);
103    }
104
105    #[test]
106    fn test_send_email_job_serialization() {
107        let email = Email::new()
108            .to("user@example.com")
109            .from("noreply@myapp.com")
110            .subject("Test")
111            .text("Hello");
112
113        let job = SendEmailJob::new(email);
114
115        // Test that the job can be serialized and deserialized
116        let serialized = serde_json::to_string(&job).unwrap();
117        let deserialized: SendEmailJob = serde_json::from_str(&serialized).unwrap();
118
119        assert_eq!(job.email.to, deserialized.email.to);
120        assert_eq!(job.email.from, deserialized.email.from);
121    }
122
123    #[tokio::test]
124    async fn test_send_email_job_execute_with_sender() {
125        let mut mock_sender = MockEmailSender::new();
126        mock_sender.expect_send().times(1).returning(|_| Ok(()));
127
128        let ctx = JobContext::new().with_email_sender(Arc::new(mock_sender));
129
130        let email = Email::new()
131            .to("user@example.com")
132            .from("noreply@myapp.com")
133            .subject("Test")
134            .text("Hello");
135
136        let job = SendEmailJob::new(email);
137
138        let result = job.execute(&ctx).await;
139        assert!(result.is_ok());
140    }
141
142    #[tokio::test]
143    async fn test_send_email_job_no_sender() {
144        let ctx = JobContext::new(); // No email sender
145
146        let email = Email::new()
147            .to("user@example.com")
148            .from("noreply@myapp.com")
149            .subject("Test")
150            .text("Hello");
151
152        let job = SendEmailJob::new(email);
153
154        let result = job.execute(&ctx).await;
155        assert!(result.is_err());
156        assert!(result.unwrap_err().to_string().contains("not available"));
157    }
158
159    #[tokio::test]
160    async fn test_send_email_job_invalid_email() {
161        let mut mock_sender = MockEmailSender::new();
162        // Should not be called because validation fails first
163        mock_sender.expect_send().times(0);
164
165        let ctx = JobContext::new().with_email_sender(Arc::new(mock_sender));
166
167        // Create an invalid email (no recipients)
168        let email = Email::new()
169            .from("noreply@myapp.com")
170            .subject("Test")
171            .text("Hello");
172
173        let job = SendEmailJob::new(email);
174
175        let result = job.execute(&ctx).await;
176        assert!(result.is_err());
177        assert!(result.unwrap_err().to_string().contains("validation failed"));
178    }
179}