Skip to main content

cloudillo_email/
task.rs

1//! Email sender task for scheduler integration
2//!
3//! Handles async, persistent email sending with template rendering.
4//! Templates are rendered at execution time, not scheduling time.
5//! Retry logic is handled by the scheduler's built-in RetryPolicy.
6
7use crate::prelude::*;
8use crate::EmailMessage;
9use async_trait::async_trait;
10use cloudillo_core::scheduler::Task;
11use serde::{Deserialize, Serialize};
12use std::fmt::Debug;
13use std::sync::Arc;
14
15pub type TaskId = u64;
16
17/// Email sender task for persistent async sending
18///
19/// Stores template name and variables instead of rendered content.
20/// Template is rendered at execution time for fresh content.
21/// Subject can be provided explicitly or extracted from template frontmatter.
22#[derive(Clone, Debug, Serialize, Deserialize)]
23pub struct EmailSenderTask {
24	pub tn_id: TnId,
25	pub to: String,
26	/// Optional subject - if None, will be extracted from template frontmatter
27	#[serde(default)]
28	pub subject: Option<String>,
29	pub template_name: String,
30	pub template_vars: serde_json::Value,
31	/// Optional language code for localized templates (e.g., "hu", "de")
32	#[serde(default)]
33	pub lang: Option<String>,
34	/// Optional sender name override (e.g., "Cloudillo (myinstance)" or identity_provider)
35	#[serde(default)]
36	pub from_name_override: Option<String>,
37}
38
39impl EmailSenderTask {
40	/// Create new email sender task from template information
41	pub fn new(
42		tn_id: TnId,
43		to: String,
44		subject: Option<String>,
45		template_name: String,
46		template_vars: serde_json::Value,
47		lang: Option<String>,
48		from_name_override: Option<String>,
49	) -> Self {
50		Self { tn_id, to, subject, template_name, template_vars, lang, from_name_override }
51	}
52}
53
54#[async_trait]
55impl Task<App> for EmailSenderTask {
56	fn kind() -> &'static str {
57		"email.send"
58	}
59
60	fn kind_of(&self) -> &'static str {
61		Self::kind()
62	}
63
64	fn build(_id: TaskId, context: &str) -> ClResult<Arc<dyn Task<App>>> {
65		// Deserialize from context
66		let task: EmailSenderTask = serde_json::from_str(context).map_err(|e| {
67			Error::ValidationError(format!("Failed to deserialize email task: {}", e))
68		})?;
69		Ok(Arc::new(task))
70	}
71
72	fn serialize(&self) -> String {
73		serde_json::to_string(self)
74			.unwrap_or_else(|_| format!("email.send:{}:{}", self.tn_id.0, self.to))
75	}
76
77	async fn run(&self, app: &App) -> ClResult<()> {
78		info!(
79			"Executing email task for {} (template: {}, lang: {:?})",
80			self.to, self.template_name, self.lang
81		);
82
83		let email_module = app.ext::<Arc<crate::EmailModule>>()?;
84
85		// Render template at execution time
86		let render_result = email_module
87			.template_engine
88			.render(self.tn_id, &self.template_name, &self.template_vars, self.lang.as_deref())
89			.await?;
90
91		// Use provided subject or extract from template
92		let subject = self.subject.clone().or(render_result.subject).ok_or_else(|| {
93			Error::ConfigError(format!(
94				"No subject provided and template '{}' has no subject in frontmatter",
95				self.template_name
96			))
97		})?;
98
99		// Build email message
100		let message = EmailMessage {
101			to: self.to.clone(),
102			subject,
103			text_body: render_result.text_body,
104			html_body: Some(render_result.html_body),
105			from_name_override: self.from_name_override.clone(),
106		};
107
108		// Send email
109		email_module.send_now(self.tn_id, message).await?;
110
111		info!("Email task completed for {}", self.to);
112		Ok(())
113	}
114}
115
116#[cfg(test)]
117mod tests {
118	use super::*;
119
120	#[test]
121	fn test_email_task_creation() {
122		let vars = serde_json::json!({
123			"user_name": "Alice",
124			"instance_name": "Cloudillo",
125		});
126
127		let task = EmailSenderTask::new(
128			TnId(1),
129			"user@example.com".to_string(),
130			Some("Test Email".to_string()),
131			"welcome".to_string(),
132			vars.clone(),
133			None,
134			None,
135		);
136
137		assert_eq!(task.tn_id.0, 1);
138		assert_eq!(task.to, "user@example.com");
139		assert_eq!(task.subject, Some("Test Email".to_string()));
140		assert_eq!(task.template_name, "welcome");
141		assert_eq!(task.template_vars, vars);
142		assert_eq!(task.lang, None);
143	}
144
145	#[test]
146	fn test_email_task_with_lang() {
147		let vars = serde_json::json!({
148			"user_name": "Béla",
149		});
150
151		let task = EmailSenderTask::new(
152			TnId(1),
153			"user@example.com".to_string(),
154			None, // Subject from template frontmatter
155			"welcome".to_string(),
156			vars.clone(),
157			Some("hu".to_string()),
158			None,
159		);
160
161		assert_eq!(task.lang, Some("hu".to_string()));
162		assert!(task.subject.is_none());
163	}
164
165	#[test]
166	fn test_email_task_serialization() {
167		let vars = serde_json::json!({
168			"user_name": "Bob",
169		});
170
171		let task = EmailSenderTask::new(
172			TnId(1),
173			"user@example.com".to_string(),
174			Some("Test".to_string()),
175			"notification".to_string(),
176			vars,
177			Some("de".to_string()),
178			None,
179		);
180
181		// Use Task trait's serialize method
182		let serialized = cloudillo_core::scheduler::Task::serialize(&task);
183
184		// Should be valid JSON
185		assert!(serialized.contains("user@example.com"));
186		assert!(serialized.contains("notification"));
187		assert!(serialized.contains("de"));
188		let deserialized: Result<EmailSenderTask, _> = serde_json::from_str(&serialized);
189		assert!(deserialized.is_ok());
190	}
191
192	#[test]
193	fn test_email_task_kind() {
194		assert_eq!(EmailSenderTask::kind(), "email.send");
195
196		let vars = serde_json::json!({});
197		let task = EmailSenderTask::new(
198			TnId(1),
199			"test@example.com".to_string(),
200			Some("Test".to_string()),
201			"test".to_string(),
202			vars,
203			None,
204			None,
205		);
206		assert_eq!(task.kind_of(), "email.send");
207	}
208
209	#[test]
210	fn test_email_task_template_vars() {
211		let vars = serde_json::json!({
212			"user_name": "Charlie",
213			"instance_name": "Cloudillo",
214			"welcome_link": "https://example.com/welcome",
215		});
216
217		let task = EmailSenderTask::new(
218			TnId(1),
219			"user@example.com".to_string(),
220			None, // Subject from frontmatter
221			"welcome".to_string(),
222			vars.clone(),
223			None,
224			None,
225		);
226
227		assert_eq!(task.template_name, "welcome");
228		assert_eq!(task.template_vars["user_name"], "Charlie");
229		assert_eq!(task.template_vars["instance_name"], "Cloudillo");
230		assert_eq!(task.template_vars["welcome_link"], "https://example.com/welcome");
231	}
232
233	#[test]
234	fn test_email_task_backward_compat() {
235		// Test that old serialized tasks without lang field still deserialize
236		let json = r#"{
237			"tn_id": 1,
238			"to": "user@example.com",
239			"subject": "Test",
240			"template_name": "test",
241			"template_vars": {}
242		}"#;
243
244		let task: Result<EmailSenderTask, _> = serde_json::from_str(json);
245		assert!(task.is_ok());
246		let task = task.unwrap();
247		assert!(task.lang.is_none());
248		// Subject should deserialize from string value
249		assert_eq!(task.subject, Some("Test".to_string()));
250	}
251}
252
253// vim: ts=4